diff --git a/.coveragerc b/.coveragerc index d5296455981..0049349cfff 100644 --- a/.coveragerc +++ b/.coveragerc @@ -102,6 +102,9 @@ omit = homeassistant/components/egardia.py homeassistant/components/*/egardia.py + homeassistant/components/elkm1/* + homeassistant/components/*/elkm1.py + homeassistant/components/enocean.py homeassistant/components/*/enocean.py @@ -245,6 +248,9 @@ omit = homeassistant/components/opencv.py homeassistant/components/*/opencv.py + homeassistant/components/opentherm_gw.py + homeassistant/components/*/opentherm_gw.py + homeassistant/components/openuv/__init__.py homeassistant/components/*/openuv.py @@ -284,6 +290,9 @@ omit = homeassistant/components/scsgate.py homeassistant/components/*/scsgate.py + homeassistant/components/simplisafe/__init__.py + homeassistant/components/*/simplisafe.py + homeassistant/components/sisyphus.py homeassistant/components/*/sisyphus.py @@ -379,7 +388,7 @@ omit = homeassistant/components/zigbee.py homeassistant/components/*/zigbee.py - homeassistant/components/zoneminder.py + homeassistant/components/zoneminder/* homeassistant/components/*/zoneminder.py homeassistant/components/tuya.py @@ -395,7 +404,6 @@ omit = homeassistant/components/alarm_control_panel/ifttt.py homeassistant/components/alarm_control_panel/manual_mqtt.py homeassistant/components/alarm_control_panel/nx584.py - homeassistant/components/alarm_control_panel/simplisafe.py homeassistant/components/alarm_control_panel/totalconnect.py homeassistant/components/alarm_control_panel/yale_smart_alarm.py homeassistant/components/apiai.py @@ -426,7 +434,6 @@ omit = homeassistant/components/camera/xeoma.py homeassistant/components/camera/xiaomi.py homeassistant/components/camera/yi.py - homeassistant/components/climate/econet.py homeassistant/components/climate/ephember.py homeassistant/components/climate/eq3btsmart.py homeassistant/components/climate/flexit.py @@ -434,8 +441,8 @@ omit = homeassistant/components/climate/homematic.py homeassistant/components/climate/honeywell.py homeassistant/components/climate/knx.py + homeassistant/components/climate/mill.py homeassistant/components/climate/oem.py - homeassistant/components/climate/opentherm_gw.py homeassistant/components/climate/proliphix.py homeassistant/components/climate/radiotherm.py homeassistant/components/climate/sensibo.py @@ -451,7 +458,6 @@ omit = homeassistant/components/cover/myq.py homeassistant/components/cover/opengarage.py homeassistant/components/cover/rpi_gpio.py - homeassistant/components/cover/ryobi_gdo.py homeassistant/components/cover/scsgate.py homeassistant/components/device_tracker/actiontec.py homeassistant/components/device_tracker/aruba.py @@ -478,6 +484,7 @@ omit = homeassistant/components/device_tracker/netgear.py homeassistant/components/device_tracker/nmap_tracker.py homeassistant/components/device_tracker/ping.py + homeassistant/components/device_tracker/quantum_gateway.py homeassistant/components/device_tracker/ritassist.py homeassistant/components/device_tracker/sky_hub.py homeassistant/components/device_tracker/snmp.py @@ -561,6 +568,7 @@ omit = homeassistant/components/media_player/itunes.py homeassistant/components/media_player/kodi.py homeassistant/components/media_player/lg_netcast.py + homeassistant/components/media_player/lg_soundbar.py homeassistant/components/media_player/liveboxplaytv.py homeassistant/components/media_player/mediaroom.py homeassistant/components/media_player/mpchc.py @@ -604,6 +612,7 @@ omit = homeassistant/components/notify/gntp.py homeassistant/components/notify/group.py homeassistant/components/notify/hipchat.py + homeassistant/components/notify/homematic.py homeassistant/components/notify/instapush.py homeassistant/components/notify/kodi.py homeassistant/components/notify/lannouncer.py @@ -636,6 +645,7 @@ omit = homeassistant/components/remember_the_milk/__init__.py homeassistant/components/remote/harmony.py homeassistant/components/remote/itach.py + homeassistant/components/route53.py homeassistant/components/scene/hunterdouglas_powerview.py homeassistant/components/scene/lifx_cloud.py homeassistant/components/sensor/airvisual.py @@ -747,6 +757,7 @@ omit = homeassistant/components/sensor/radarr.py homeassistant/components/sensor/rainbird.py homeassistant/components/sensor/ripple.py + homeassistant/components/sensor/rtorrent.py homeassistant/components/sensor/scrape.py homeassistant/components/sensor/sense.py homeassistant/components/sensor/sensehat.py @@ -776,6 +787,7 @@ omit = homeassistant/components/sensor/tank_utility.py homeassistant/components/sensor/ted5000.py homeassistant/components/sensor/temper.py + homeassistant/components/sensor/thermoworks_smoke.py homeassistant/components/sensor/time_date.py homeassistant/components/sensor/torque.py homeassistant/components/sensor/trafikverket_weatherstation.py @@ -816,6 +828,7 @@ omit = homeassistant/components/switch/pulseaudio_loopback.py homeassistant/components/switch/rainbird.py homeassistant/components/switch/rest.py + homeassistant/components/switch/recswitch.py homeassistant/components/switch/rpi_rf.py homeassistant/components/switch/snmp.py homeassistant/components/switch/switchbot.py @@ -832,6 +845,7 @@ omit = homeassistant/components/tts/picotts.py homeassistant/components/vacuum/mqtt.py homeassistant/components/vacuum/roomba.py + homeassistant/components/water_heater/econet.py homeassistant/components/watson_iot.py homeassistant/components/weather/bom.py homeassistant/components/weather/buienradar.py diff --git a/CODEOWNERS b/CODEOWNERS index 91d5fd67670..c49af4864a9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -2,59 +2,68 @@ # when the code that they own is touched. # https://github.com/blog/2392-introducing-code-owners +# Home Assistant Core setup.py @home-assistant/core homeassistant/*.py @home-assistant/core homeassistant/helpers/* @home-assistant/core homeassistant/util/* @home-assistant/core homeassistant/components/api.py @home-assistant/core +homeassistant/components/auth/* @home-assistant/core homeassistant/components/automation/* @home-assistant/core +homeassistant/components/cloud/* @home-assistant/core +homeassistant/components/config/* @home-assistant/core homeassistant/components/configurator.py @home-assistant/core -homeassistant/components/group.py @home-assistant/core +homeassistant/components/conversation/* @home-assistant/core +homeassistant/components/frontend/* @home-assistant/core +homeassistant/components/group/* @home-assistant/core homeassistant/components/history.py @home-assistant/core homeassistant/components/http/* @home-assistant/core homeassistant/components/input_*.py @home-assistant/core homeassistant/components/introduction.py @home-assistant/core homeassistant/components/logger.py @home-assistant/core +homeassistant/components/lovelace/* @home-assistant/core homeassistant/components/mqtt/* @home-assistant/core homeassistant/components/panel_custom.py @home-assistant/core homeassistant/components/panel_iframe.py @home-assistant/core -homeassistant/components/persistent_notification.py @home-assistant/core +homeassistant/components/onboarding/* @home-assistant/core +homeassistant/components/persistent_notification/* @home-assistant/core homeassistant/components/scene/__init__.py @home-assistant/core homeassistant/components/scene/hass.py @home-assistant/core homeassistant/components/script.py @home-assistant/core homeassistant/components/shell_command.py @home-assistant/core homeassistant/components/sun.py @home-assistant/core homeassistant/components/updater.py @home-assistant/core -homeassistant/components/weblink.py @home-assistant/core +homeassistant/components/weblink/* @home-assistant/core homeassistant/components/websocket_api.py @home-assistant/core -homeassistant/components/zone.py @home-assistant/core +homeassistant/components/zone/* @home-assistant/core -# HomeAssistant developer Teams +# Home Assistant Developer Teams Dockerfile @home-assistant/docker virtualization/Docker/* @home-assistant/docker homeassistant/components/zwave/* @home-assistant/z-wave homeassistant/components/*/zwave.py @home-assistant/z-wave -homeassistant/components/hassio.py @home-assistant/hassio +homeassistant/components/hassio/* @home-assistant/hassio -# Individual components +# Individual platforms homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt homeassistant/components/alarm_control_panel/manual_mqtt.py @colinodell -homeassistant/components/alarm_control_panel/simplisafe.py @bachya homeassistant/components/binary_sensor/hikvision.py @mezz64 -homeassistant/components/bmw_connected_drive.py @ChristianKuehnel +homeassistant/components/binary_sensor/threshold.py @fabaff homeassistant/components/camera/yi.py @bachya homeassistant/components/climate/ephember.py @ttroy50 homeassistant/components/climate/eq3btsmart.py @rytilahti +homeassistant/components/climate/mill.py @danielhiversen homeassistant/components/climate/sensibo.py @andrey-git homeassistant/components/cover/group.py @cdce8p homeassistant/components/cover/template.py @PhracturedBlue homeassistant/components/device_tracker/automatic.py @armills 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/history_graph.py @andrey-git -homeassistant/components/light/lifx.py @amelchio +homeassistant/components/influx.py @fabaff homeassistant/components/light/lifx_legacy.py @amelchio homeassistant/components/light/tplink.py @rytilahti homeassistant/components/light/yeelight.py @rytilahti @@ -65,74 +74,173 @@ homeassistant/components/media_player/kodi.py @armills homeassistant/components/media_player/liveboxplaytv.py @pschmitt homeassistant/components/media_player/mediaroom.py @dgomes homeassistant/components/media_player/monoprice.py @etsinko +homeassistant/components/media_player/mpd.py @fabaff homeassistant/components/media_player/sonos.py @amelchio homeassistant/components/media_player/xiaomi_tv.py @fattdev homeassistant/components/media_player/yamaha_musiccast.py @jalmeroth +homeassistant/components/no_ip.py @fabaff +homeassistant/components/notify/file.py @fabaff +homeassistant/components/notify/flock.py @fabaff +homeassistant/components/notify/instapush.py @fabaff +homeassistant/components/notify/mastodon.py @fabaff +homeassistant/components/notify/smtp.py @fabaff +homeassistant/components/notify/syslog.py @fabaff +homeassistant/components/notify/xmpp.py @fabaff homeassistant/components/plant.py @ChristianKuehnel homeassistant/components/scene/lifx_cloud.py @amelchio homeassistant/components/sensor/airvisual.py @bachya +homeassistant/components/sensor/alpha_vantage.py @fabaff +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/file.py @fabaff homeassistant/components/sensor/filter.py @dgomes +homeassistant/components/sensor/fixer.py @fabaff homeassistant/components/sensor/gearbest.py @HerrHofrat +homeassistant/components/sensor/gitter.py @fabaff +homeassistant/components/sensor/glances.py @fabaff +homeassistant/components/sensor/gpsd.py @fabaff homeassistant/components/sensor/irish_rail_transport.py @ttroy50 homeassistant/components/sensor/jewish_calendar.py @tsvi +homeassistant/components/sensor/linux_battery.py @fabaff +homeassistant/components/sensor/luftdaten.py @fabaff 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/nsw_fuel_station.py @nickw444 +homeassistant/components/sensor/pi_hole.py @fabaff homeassistant/components/sensor/pollen.py @bachya +homeassistant/components/sensor/pvoutput.py @fabaff homeassistant/components/sensor/qnap.py @colinodell +homeassistant/components/sensor/scrape.py @fabaff +homeassistant/components/sensor/serial.py @fabaff +homeassistant/components/sensor/shodan.py @fabaff homeassistant/components/sensor/sma.py @kellerza homeassistant/components/sensor/sql.py @dgomes +homeassistant/components/sensor/statistics.py @fabaff +homeassistant/components/sensor/swiss*.py @fabaff homeassistant/components/sensor/sytadin.py @gautric +homeassistant/components/sensor/time_data.py @fabaff +homeassistant/components/sensor/version.py @fabaff homeassistant/components/sensor/waqi.py @andrey-git +homeassistant/components/sensor/worldclock.py @fabaff +homeassistant/components/shiftr.py @fabaff +homeassistant/components/spaceapi.py @fabaff homeassistant/components/switch/tplink.py @rytilahti homeassistant/components/vacuum/roomba.py @pschmitt +homeassistant/components/weather/__init__.py @fabaff +homeassistant/components/weather/darksky.py @fabaff +homeassistant/components/weather/demo.py @fabaff +homeassistant/components/weather/met.py @danielhiversen +homeassistant/components/weather/openweathermap.py @fabaff homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi +# A +homeassistant/components/arduino.py @fabaff +homeassistant/components/*/arduino.py @fabaff +homeassistant/components/*/arest.py @fabaff homeassistant/components/*/axis.py @kane610 + +# B homeassistant/components/blink/* @fronzbot homeassistant/components/*/blink.py @fronzbot +homeassistant/components/bmw_connected_drive.py @ChristianKuehnel homeassistant/components/*/bmw_connected_drive.py @ChristianKuehnel homeassistant/components/*/broadlink.py @danielhiversen + +# C +homeassistant/components/counter/* @fabaff + +# D homeassistant/components/*/deconz.py @kane610 +homeassistant/components/digital_ocean.py @fabaff +homeassistant/components/*/digital_ocean.py @fabaff +homeassistant/components/dweet.py @fabaff +homeassistant/components/*/dweet.py @fabaff + +# E homeassistant/components/ecovacs.py @OverloadUT homeassistant/components/*/ecovacs.py @OverloadUT -homeassistant/components/edp_redy.py @abmantis homeassistant/components/*/edp_redy.py @abmantis +homeassistant/components/edp_redy.py @abmantis homeassistant/components/eight_sleep.py @mezz64 homeassistant/components/*/eight_sleep.py @mezz64 + +# H homeassistant/components/hive.py @Rendili @KJonline homeassistant/components/*/hive.py @Rendili @KJonline homeassistant/components/homekit/* @cdce8p homeassistant/components/huawei_lte.py @scop homeassistant/components/*/huawei_lte.py @scop + +# K homeassistant/components/knx.py @Julius2342 homeassistant/components/*/knx.py @Julius2342 homeassistant/components/konnected.py @heythisisnate homeassistant/components/*/konnected.py @heythisisnate + +# L +homeassistant/components/lifx.py @amelchio +homeassistant/components/*/lifx.py @amelchio + +# M homeassistant/components/matrix.py @tinloaf homeassistant/components/*/matrix.py @tinloaf +homeassistant/components/melissa.py @kennedyshead +homeassistant/components/*/melissa.py @kennedyshead +homeassistant/components/*/mystrom.py @fabaff + +# O homeassistant/components/openuv/* @bachya homeassistant/components/*/openuv.py @bachya + +# Q homeassistant/components/qwikswitch.py @kellerza homeassistant/components/*/qwikswitch.py @kellerza + +# R homeassistant/components/rainmachine/* @bachya homeassistant/components/*/rainmachine.py @bachya +homeassistant/components/*/random.py @fabaff homeassistant/components/*/rfxtrx.py @danielhiversen + +# S +homeassistant/components/simplisafe/* @bachya +homeassistant/components/*/simplisafe.py @bachya + +# T homeassistant/components/tahoma.py @philklei homeassistant/components/*/tahoma.py @philklei -homeassistant/components/tesla.py @zabuldon -homeassistant/components/*/tesla.py @zabuldon homeassistant/components/tellduslive.py @molobrakos @fredrike homeassistant/components/*/tellduslive.py @molobrakos @fredrike +homeassistant/components/tesla.py @zabuldon +homeassistant/components/*/tesla.py @zabuldon +homeassistant/components/thethingsnetwork.py @fabaff +homeassistant/components/*/thethingsnetwork.py @fabaff homeassistant/components/tibber/* @danielhiversen homeassistant/components/*/tibber.py @danielhiversen +homeassistant/components/tradfri/* @ggravlingen homeassistant/components/*/tradfri.py @ggravlingen + +# U +homeassistant/components/unifi.py @kane610 +homeassistant/components/switch/unifi.py @kane610 homeassistant/components/upcloud.py @scop homeassistant/components/*/upcloud.py @scop + +# V homeassistant/components/velux.py @Julius2342 homeassistant/components/*/velux.py @Julius2342 + +# X homeassistant/components/*/xiaomi_aqara.py @danielhiversen @syssi homeassistant/components/*/xiaomi_miio.py @rytilahti @syssi -homeassistant/components/zoneminder.py @rohankapoorcom + +# Z +homeassistant/components/zoneminder/ @rohankapoorcom homeassistant/components/*/zoneminder.py @rohankapoorcom +# Other code homeassistant/scripts/check_config.py @kellerza diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index c6f978640f6..e584d5b70e5 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -16,6 +16,9 @@ from . import auth_store, models from .mfa_modules import auth_mfa_module_from_config, MultiFactorAuthModule from .providers import auth_provider_from_config, AuthProvider, LoginFlow +EVENT_USER_ADDED = 'user_added' +EVENT_USER_REMOVED = 'user_removed' + _LOGGER = logging.getLogger(__name__) _MfaModuleDict = Dict[str, MultiFactorAuthModule] _ProviderKey = Tuple[str, Optional[str]] @@ -126,23 +129,38 @@ class AuthManager: async def async_create_system_user(self, name: str) -> models.User: """Create a system user.""" - return await self._store.async_create_user( + user = await self._store.async_create_user( name=name, system_generated=True, is_active=True, + groups=[], ) + self.hass.bus.async_fire(EVENT_USER_ADDED, { + 'user_id': user.id + }) + + return user + async def async_create_user(self, name: str) -> models.User: """Create a user.""" + group = (await self._store.async_get_groups())[0] kwargs = { 'name': name, 'is_active': True, + 'groups': [group] } # type: Dict[str, Any] if await self._user_should_be_owner(): kwargs['is_owner'] = True - return await self._store.async_create_user(**kwargs) + user = await self._store.async_create_user(**kwargs) + + self.hass.bus.async_fire(EVENT_USER_ADDED, { + 'user_id': user.id + }) + + return user async def async_get_or_create_user(self, credentials: models.Credentials) \ -> models.User: @@ -162,12 +180,18 @@ class AuthManager: info = await auth_provider.async_user_meta_for_credentials( credentials) - return await self._store.async_create_user( + user = await self._store.async_create_user( credentials=credentials, name=info.name, is_active=info.is_active, ) + self.hass.bus.async_fire(EVENT_USER_ADDED, { + 'user_id': user.id + }) + + return user + async def async_link_user(self, user: models.User, credentials: models.Credentials) -> None: """Link credentials to an existing user.""" @@ -185,6 +209,10 @@ class AuthManager: await self._store.async_remove_user(user) + self.hass.bus.async_fire(EVENT_USER_REMOVED, { + 'user_id': user.id + }) + async def async_activate_user(self, user: models.User) -> None: """Activate a user.""" await self._store.async_activate_user(user) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 54c34d8ec2c..8c328bfe13e 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -10,9 +10,11 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.util import dt as dt_util from . import models +from .permissions import DEFAULT_POLICY STORAGE_VERSION = 1 STORAGE_KEY = 'auth' +INITIAL_GROUP_NAME = 'All Access' class AuthStore: @@ -28,9 +30,18 @@ class AuthStore: """Initialize the auth store.""" self.hass = hass self._users = None # type: Optional[Dict[str, models.User]] + self._groups = None # type: Optional[Dict[str, models.Group]] self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY, private=True) + async def async_get_groups(self) -> List[models.Group]: + """Retrieve all users.""" + if self._groups is None: + await self._async_load() + assert self._groups is not None + + return list(self._groups.values()) + async def async_get_users(self) -> List[models.User]: """Retrieve all users.""" if self._users is None: @@ -51,14 +62,20 @@ class AuthStore: self, name: Optional[str], is_owner: Optional[bool] = None, is_active: Optional[bool] = None, system_generated: Optional[bool] = None, - credentials: Optional[models.Credentials] = None) -> models.User: + credentials: Optional[models.Credentials] = None, + groups: Optional[List[models.Group]] = None) -> models.User: """Create a new user.""" if self._users is None: await self._async_load() - assert self._users is not None + + assert self._users is not None + assert self._groups is not None kwargs = { - 'name': name + 'name': name, + # Until we get group management, we just put everyone in the + # same group. + 'groups': groups or [], } # type: Dict[str, Any] if is_owner is not None: @@ -219,19 +236,40 @@ class AuthStore: return users = OrderedDict() # type: Dict[str, models.User] + groups = OrderedDict() # type: Dict[str, models.Group] # When creating objects we mention each attribute explicetely. This # prevents crashing if user rolls back HA version after a new property # was added. + for group_dict in data.get('groups', []): + groups[group_dict['id']] = models.Group( + name=group_dict['name'], + id=group_dict['id'], + policy=group_dict.get('policy', DEFAULT_POLICY), + ) + + migrate_group = None + + if not groups: + migrate_group = models.Group( + name=INITIAL_GROUP_NAME, + policy=DEFAULT_POLICY + ) + groups[migrate_group.id] = migrate_group + for user_dict in data['users']: users[user_dict['id']] = models.User( name=user_dict['name'], + groups=[groups[group_id] for group_id + in user_dict.get('group_ids', [])], id=user_dict['id'], is_owner=user_dict['is_owner'], is_active=user_dict['is_active'], system_generated=user_dict['system_generated'], ) + if migrate_group is not None and not user_dict['system_generated']: + users[user_dict['id']].groups = [migrate_group] for cred_dict in data['credentials']: users[cred_dict['user_id']].credentials.append(models.Credentials( @@ -286,6 +324,7 @@ class AuthStore: ) users[rt_dict['user_id']].refresh_tokens[token.id] = token + self._groups = groups self._users = users @callback @@ -300,10 +339,12 @@ class AuthStore: def _data_to_save(self) -> Dict: """Return the data to store.""" assert self._users is not None + assert self._groups is not None users = [ { 'id': user.id, + 'group_ids': [group.id for group in user.groups], 'is_owner': user.is_owner, 'is_active': user.is_active, 'name': user.name, @@ -312,6 +353,18 @@ class AuthStore: for user in self._users.values() ] + groups = [] + for group in self._groups.values(): + g_dict = { + 'name': group.name, + 'id': group.id, + } # type: Dict[str, Any] + + if group.policy is not DEFAULT_POLICY: + g_dict['policy'] = group.policy + + groups.append(g_dict) + credentials = [ { 'id': credential.id, @@ -348,6 +401,7 @@ class AuthStore: return { 'users': users, + 'groups': groups, 'credentials': credentials, 'refresh_tokens': refresh_tokens, } @@ -355,3 +409,14 @@ class AuthStore: def _set_defaults(self) -> None: """Set default values for auth store.""" self._users = OrderedDict() # type: Dict[str, models.User] + + # Add default group + all_access_group = models.Group( + name=INITIAL_GROUP_NAME, + policy=DEFAULT_POLICY, + ) + + groups = OrderedDict() # type: Dict[str, models.Group] + groups[all_access_group.id] = all_access_group + + self._groups = groups diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index bd00ca72b83..fc35f1398db 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -7,6 +7,7 @@ import attr from homeassistant.util import dt as dt_util +from . import permissions as perm_mdl from .util import generate_secret TOKEN_TYPE_NORMAL = 'normal' @@ -14,6 +15,15 @@ TOKEN_TYPE_SYSTEM = 'system' TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = 'long_lived_access_token' +@attr.s(slots=True) +class Group: + """A group.""" + + name = attr.ib(type=str) # type: Optional[str] + policy = attr.ib(type=perm_mdl.PolicyType) + id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex) + + @attr.s(slots=True) class User: """A user.""" @@ -24,6 +34,8 @@ class User: is_active = attr.ib(type=bool, default=False) system_generated = attr.ib(type=bool, default=False) + groups = attr.ib(type=List, factory=list, cmp=False) # type: List[Group] + # List of credentials of a user. credentials = attr.ib( type=list, factory=list, cmp=False @@ -34,6 +46,28 @@ class User: type=dict, factory=dict, cmp=False ) # type: Dict[str, RefreshToken] + _permissions = attr.ib( + type=perm_mdl.PolicyPermissions, + init=False, + cmp=False, + default=None, + ) + + @property + def permissions(self) -> perm_mdl.AbstractPermissions: + """Return permissions object for user.""" + if self.is_owner: + return perm_mdl.OwnerPermissions + + if self._permissions is not None: + return self._permissions + + self._permissions = perm_mdl.PolicyPermissions( + perm_mdl.merge_policies([ + group.policy for group in self.groups])) + + return self._permissions + @attr.s(slots=True) class RefreshToken: diff --git a/homeassistant/auth/permissions.py b/homeassistant/auth/permissions.py new file mode 100644 index 00000000000..82de61da7f9 --- /dev/null +++ b/homeassistant/auth/permissions.py @@ -0,0 +1,252 @@ +"""Permissions for Home Assistant.""" +from typing import ( # noqa: F401 + cast, Any, Callable, Dict, List, Mapping, Set, Tuple, Union) + +import voluptuous as vol + +from homeassistant.core import State + +CategoryType = Union[Mapping[str, 'CategoryType'], bool, None] +PolicyType = Mapping[str, CategoryType] + + +# Default policy if group has no policy applied. +DEFAULT_POLICY = { + "entities": True +} # type: PolicyType + +CAT_ENTITIES = 'entities' +ENTITY_DOMAINS = 'domains' +ENTITY_ENTITY_IDS = 'entity_ids' + +VALUES_SCHEMA = vol.Any(True, vol.Schema({ + str: True +})) + +ENTITY_POLICY_SCHEMA = vol.Any(True, vol.Schema({ + vol.Optional(ENTITY_DOMAINS): VALUES_SCHEMA, + vol.Optional(ENTITY_ENTITY_IDS): VALUES_SCHEMA, +})) + +POLICY_SCHEMA = vol.Schema({ + vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA +}) + + +class AbstractPermissions: + """Default permissions class.""" + + def check_entity(self, entity_id: str, *keys: str) -> bool: + """Test if we can access entity.""" + raise NotImplementedError + + def filter_states(self, states: List[State]) -> List[State]: + """Filter a list of states for what the user is allowed to see.""" + raise NotImplementedError + + +class PolicyPermissions(AbstractPermissions): + """Handle permissions.""" + + def __init__(self, policy: PolicyType) -> None: + """Initialize the permission class.""" + self._policy = policy + self._compiled = {} # type: Dict[str, Callable[..., bool]] + + def check_entity(self, entity_id: str, *keys: str) -> bool: + """Test if we can access entity.""" + func = self._policy_func(CAT_ENTITIES, _compile_entities) + return func(entity_id, keys) + + def filter_states(self, states: List[State]) -> List[State]: + """Filter a list of states for what the user is allowed to see.""" + func = self._policy_func(CAT_ENTITIES, _compile_entities) + keys = ('read',) + return [entity for entity in states if func(entity.entity_id, keys)] + + def _policy_func(self, category: str, + compile_func: Callable[[CategoryType], Callable]) \ + -> Callable[..., bool]: + """Get a policy function.""" + func = self._compiled.get(category) + + if func: + return func + + func = self._compiled[category] = compile_func( + self._policy.get(category)) + return func + + def __eq__(self, other: Any) -> bool: + """Equals check.""" + # pylint: disable=protected-access + return (isinstance(other, PolicyPermissions) and + other._policy == self._policy) + + +class _OwnerPermissions(AbstractPermissions): + """Owner permissions.""" + + # pylint: disable=no-self-use + + def check_entity(self, entity_id: str, *keys: str) -> bool: + """Test if we can access entity.""" + return True + + def filter_states(self, states: List[State]) -> List[State]: + """Filter a list of states for what the user is allowed to see.""" + return states + + +OwnerPermissions = _OwnerPermissions() # pylint: disable=invalid-name + + +def _compile_entities(policy: CategoryType) \ + -> Callable[[str, Tuple[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, keys: Tuple[str]) -> bool: + """Decline all.""" + return False + + return apply_policy_deny_all + + if policy is True: + def apply_policy_allow_all(entity_id: str, keys: Tuple[str]) -> bool: + """Approve all.""" + return True + + return apply_policy_allow_all + + assert isinstance(policy, dict) + + domains = policy.get(ENTITY_DOMAINS) + entity_ids = policy.get(ENTITY_ENTITY_IDS) + + funcs = [] # type: List[Callable[[str, Tuple[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 apply_entity_id_policy(entity_id: str, keys: Tuple[str]) -> bool: + """Test if allowed entity_id.""" + return entity_ids # type: ignore + + return apply_entity_id_policy + + if entity_ids is not None: + def allowed_entity_id(entity_id: str, keys: Tuple[str]) \ + -> Union[None, bool]: + """Test if allowed entity_id.""" + return entity_ids.get(entity_id) # type: ignore + + funcs.append(allowed_entity_id) + + if isinstance(domains, bool): + def allowed_domain(entity_id: str, keys: Tuple[str]) \ + -> Union[None, bool]: + """Test if allowed domain.""" + return domains + + funcs.append(allowed_domain) + + elif domains is not None: + def allowed_domain(entity_id: str, keys: Tuple[str]) \ + -> Union[None, bool]: + """Test if allowed domain.""" + domain = entity_id.split(".", 1)[0] + return domains.get(domain) # type: ignore + + funcs.append(allowed_domain) + + # Can happen if no valid subcategories specified + if not funcs: + def apply_policy_deny_all_2(entity_id: str, keys: Tuple[str]) -> bool: + """Decline all.""" + return False + + return apply_policy_deny_all_2 + + if len(funcs) == 1: + func = funcs[0] + + def apply_policy_func(entity_id: str, keys: Tuple[str]) -> bool: + """Apply a single policy function.""" + return func(entity_id, keys) is True + + return apply_policy_func + + def apply_policy_funcs(entity_id: str, keys: Tuple[str]) -> bool: + """Apply several policy functions.""" + for func in funcs: + result = func(entity_id, keys) + if result is not None: + return result + return False + + return apply_policy_funcs + + +def merge_policies(policies: List[PolicyType]) -> PolicyType: + """Merge policies.""" + new_policy = {} # type: Dict[str, CategoryType] + seen = set() # type: Set[str] + for policy in policies: + for category in policy: + if category in seen: + continue + seen.add(category) + new_policy[category] = _merge_policies([ + policy.get(category) for policy in policies]) + cast(PolicyType, new_policy) + return new_policy + + +def _merge_policies(sources: List[CategoryType]) -> CategoryType: + """Merge a policy.""" + # When merging policies, the most permissive wins. + # This means we order it like this: + # True > Dict > None + # + # True: allow everything + # Dict: specify more granular permissions + # None: no opinion + # + # If there are multiple sources with a dict as policy, we recursively + # merge each key in the source. + + policy = None # type: CategoryType + seen = set() # type: Set[str] + for source in sources: + if source is None: + continue + + # A source that's True will always win. Shortcut return. + if source is True: + return True + + assert isinstance(source, dict) + + if policy is None: + policy = {} + + assert isinstance(policy, dict) + + for key in source: + if key in seen: + continue + seen.add(key) + + key_sources = [] + for src in sources: + if isinstance(src, dict): + key_sources.append(src.get(key)) + + policy[key] = _merge_policies(key_sources) + + return policy diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index e2701ee37f1..bdb89dd60fa 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -12,6 +12,8 @@ import itertools as it import logging from typing import Awaitable +import voluptuous as vol + import homeassistant.core as ha import homeassistant.config as conf_util from homeassistant.exceptions import HomeAssistantError @@ -21,11 +23,16 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART, RESTART_EXIT_CODE) +from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) SERVICE_RELOAD_CORE_CONFIG = 'reload_core_config' SERVICE_CHECK_CONFIG = 'check_config' +SERVICE_UPDATE_ENTITY = 'update_entity' +SCHEMA_UPDATE_ENTITY = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_id +}) def is_on(hass, entity_id=None): @@ -133,12 +140,20 @@ async def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]: if call.service == SERVICE_HOMEASSISTANT_RESTART: hass.async_create_task(hass.async_stop(RESTART_EXIT_CODE)) + async def async_handle_update_service(call): + """Service handler for updating an entity.""" + await hass.helpers.entity_component.async_update_entity( + call.data[ATTR_ENTITY_ID]) + hass.services.async_register( ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service) hass.services.async_register( ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service) hass.services.async_register( ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service) + hass.services.async_register( + ha.DOMAIN, SERVICE_UPDATE_ENTITY, async_handle_update_service, + schema=SCHEMA_UPDATE_ENTITY) async def async_handle_reload_config(call): """Service handler for reloading core config.""" diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index 64bedb4ac7c..99bc026a532 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -18,7 +18,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['abodepy==0.13.1'] +REQUIREMENTS = ['abodepy==0.14.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/alarm_control_panel/elkm1.py b/homeassistant/components/alarm_control_panel/elkm1.py new file mode 100644 index 00000000000..7b8d2e4ac42 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/elkm1.py @@ -0,0 +1,204 @@ +""" +Each ElkM1 area will be created as a separate alarm_control_panel in HASS. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.elkm1/ +""" + +import voluptuous as vol +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.const import ( + ATTR_CODE, ATTR_ENTITY_ID, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMING, STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED) +from homeassistant.components.elkm1 import ( + DOMAIN as ELK_DOMAIN, create_elk_entities, ElkEntity) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send) + +DEPENDENCIES = [ELK_DOMAIN] + +SIGNAL_ARM_ENTITY = 'elkm1_arm' +SIGNAL_DISPLAY_MESSAGE = 'elkm1_display_message' + +ELK_ALARM_SERVICE_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID, default=[]): cv.entity_ids, + vol.Required(ATTR_CODE): vol.All(vol.Coerce(int), vol.Range(0, 999999)), +}) + +DISPLAY_MESSAGE_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID, default=[]): cv.entity_ids, + vol.Optional('clear', default=2): vol.In([0, 1, 2]), + vol.Optional('beep', default=False): cv.boolean, + vol.Optional('timeout', default=0): vol.Range(min=0, max=65535), + vol.Optional('line1', default=''): cv.string, + vol.Optional('line2', default=''): cv.string, +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the ElkM1 alarm platform.""" + if discovery_info is None: + return + + elk = hass.data[ELK_DOMAIN]['elk'] + entities = create_elk_entities(hass, elk.areas, 'area', ElkArea, []) + async_add_entities(entities, True) + + def _dispatch(signal, entity_ids, *args): + for entity_id in entity_ids: + async_dispatcher_send( + hass, '{}_{}'.format(signal, entity_id), *args) + + def _arm_service(service): + entity_ids = service.data.get(ATTR_ENTITY_ID, []) + arm_level = _arm_services().get(service.service) + args = (arm_level, service.data.get(ATTR_CODE)) + _dispatch(SIGNAL_ARM_ENTITY, entity_ids, *args) + + for service in _arm_services(): + hass.services.async_register( + alarm.DOMAIN, service, _arm_service, ELK_ALARM_SERVICE_SCHEMA) + + def _display_message_service(service): + entity_ids = service.data.get(ATTR_ENTITY_ID, []) + data = service.data + args = (data['clear'], data['beep'], data['timeout'], + data['line1'], data['line2']) + _dispatch(SIGNAL_DISPLAY_MESSAGE, entity_ids, *args) + + hass.services.async_register( + alarm.DOMAIN, 'elkm1_alarm_display_message', + _display_message_service, DISPLAY_MESSAGE_SERVICE_SCHEMA) + + +def _arm_services(): + from elkm1_lib.const import ArmLevel + + return { + 'elkm1_alarm_arm_vacation': ArmLevel.ARMED_VACATION.value, + 'elkm1_alarm_arm_home_instant': ArmLevel.ARMED_STAY_INSTANT.value, + 'elkm1_alarm_arm_night_instant': ArmLevel.ARMED_NIGHT_INSTANT.value, + } + + +class ElkArea(ElkEntity, alarm.AlarmControlPanel): + """Representation of an Area / Partition within the ElkM1 alarm panel.""" + + def __init__(self, element, elk, elk_data): + """Initialize Area as Alarm Control Panel.""" + super().__init__(element, elk, elk_data) + self._changed_by_entity_id = '' + self._state = None + + async def async_added_to_hass(self): + """Register callback for ElkM1 changes.""" + await super().async_added_to_hass() + for keypad in self._elk.keypads: + keypad.add_callback(self._watch_keypad) + async_dispatcher_connect( + self.hass, '{}_{}'.format(SIGNAL_ARM_ENTITY, self.entity_id), + self._arm_service) + async_dispatcher_connect( + self.hass, '{}_{}'.format(SIGNAL_DISPLAY_MESSAGE, self.entity_id), + self._display_message) + + def _watch_keypad(self, keypad, changeset): + if keypad.area != self._element.index: + return + if changeset.get('last_user') is not None: + self._changed_by_entity_id = self.hass.data[ + ELK_DOMAIN]['keypads'].get(keypad.index, '') + self.async_schedule_update_ha_state(True) + + @property + def code_format(self): + """Return the alarm code format.""" + return '^[0-9]{4}([0-9]{2})?$' + + @property + def state(self): + """Return the state of the element.""" + return self._state + + @property + def device_state_attributes(self): + """Attributes of the area.""" + from elkm1_lib.const import AlarmState, ArmedStatus, ArmUpState + + attrs = self.initial_attrs() + elmt = self._element + attrs['is_exit'] = elmt.is_exit + attrs['timer1'] = elmt.timer1 + attrs['timer2'] = elmt.timer2 + if elmt.armed_status is not None: + attrs['armed_status'] = \ + ArmedStatus(elmt.armed_status).name.lower() + if elmt.arm_up_state is not None: + attrs['arm_up_state'] = ArmUpState(elmt.arm_up_state).name.lower() + if elmt.alarm_state is not None: + attrs['alarm_state'] = AlarmState(elmt.alarm_state).name.lower() + attrs['changed_by_entity_id'] = self._changed_by_entity_id + return attrs + + def _element_changed(self, element, changeset): + from elkm1_lib.const import ArmedStatus + + elk_state_to_hass_state = { + ArmedStatus.DISARMED.value: STATE_ALARM_DISARMED, + ArmedStatus.ARMED_AWAY.value: STATE_ALARM_ARMED_AWAY, + ArmedStatus.ARMED_STAY.value: STATE_ALARM_ARMED_HOME, + ArmedStatus.ARMED_STAY_INSTANT.value: STATE_ALARM_ARMED_HOME, + ArmedStatus.ARMED_TO_NIGHT.value: STATE_ALARM_ARMED_NIGHT, + ArmedStatus.ARMED_TO_NIGHT_INSTANT.value: STATE_ALARM_ARMED_NIGHT, + ArmedStatus.ARMED_TO_VACATION.value: STATE_ALARM_ARMED_AWAY, + } + + if self._element.alarm_state is None: + self._state = None + elif self._area_is_in_alarm_state(): + self._state = STATE_ALARM_TRIGGERED + elif self._entry_exit_timer_is_running(): + self._state = STATE_ALARM_ARMING \ + if self._element.is_exit else STATE_ALARM_PENDING + else: + self._state = elk_state_to_hass_state[self._element.armed_status] + + def _entry_exit_timer_is_running(self): + return self._element.timer1 > 0 or self._element.timer2 > 0 + + def _area_is_in_alarm_state(self): + from elkm1_lib.const import AlarmState + + return self._element.alarm_state >= AlarmState.FIRE_ALARM.value + + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + self._element.disarm(int(code)) + + async def async_alarm_arm_home(self, code=None): + """Send arm home command.""" + from elkm1_lib.const import ArmLevel + + self._element.arm(ArmLevel.ARMED_STAY.value, int(code)) + + async def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + from elkm1_lib.const import ArmLevel + + self._element.arm(ArmLevel.ARMED_AWAY.value, int(code)) + + async def async_alarm_arm_night(self, code=None): + """Send arm night command.""" + from elkm1_lib.const import ArmLevel + + self._element.arm(ArmLevel.ARMED_NIGHT.value, int(code)) + + async def _arm_service(self, arm_level, code): + self._element.arm(arm_level, code) + + async def _display_message(self, clear, beep, timeout, line1, line2): + """Display a message on all keypads for the area.""" + self._element.display_message(clear, beep, timeout, line1, line2) diff --git a/homeassistant/components/alarm_control_panel/services.yaml b/homeassistant/components/alarm_control_panel/services.yaml index 391de2033c7..7918631464f 100644 --- a/homeassistant/components/alarm_control_panel/services.yaml +++ b/homeassistant/components/alarm_control_panel/services.yaml @@ -79,3 +79,55 @@ ifttt_push_alarm_state: state: description: The state to which the alarm control panel has to be set. example: 'armed_night' + +elkm1_alarm_arm_vacation: + description: Arm the ElkM1 in vacation mode. + fields: + entity_id: + description: Name of alarm control panel to arm. + example: 'alarm_control_panel.main' + code: + description: An code to arm the alarm control panel. + example: 1234 + +elkm1_alarm_arm_home_instant: + description: Arm the ElkM1 in home instant mode. + fields: + entity_id: + description: Name of alarm control panel to arm. + example: 'alarm_control_panel.main' + code: + description: An code to arm the alarm control panel. + example: 1234 + +elkm1_alarm_arm_night_instant: + description: Arm the ElkM1 in night instant mode. + fields: + entity_id: + description: Name of alarm control panel to arm. + example: 'alarm_control_panel.main' + code: + description: An code to arm the alarm control panel. + example: 1234 + +elkm1_alarm_display_message: + description: Display a message on all of the ElkM1 keypads for an area. + fields: + entity_id: + description: Name of alarm control panel to display messages on. + example: 'alarm_control_panel.main' + clear: + description: 0=clear message, 1=clear message with * key, 2=Display until timeout; default 2 + example: 1 + beep: + description: 0=no beep, 1=beep; default 0 + example: 1 + timeout: + description: Time to display message, 0=forever, max 65535, default 0 + example: 4242 + line1: + description: Up to 16 characters of text (truncated if too long). Default blank. + example: The answer to life, + line2: + description: Up to 16 characters of text (truncated if too long). Default blank. + example: the universe, and everything. diff --git a/homeassistant/components/alarm_control_panel/simplisafe.py b/homeassistant/components/alarm_control_panel/simplisafe.py index 34c68f26c2a..cdcdf07c982 100644 --- a/homeassistant/components/alarm_control_panel/simplisafe.py +++ b/homeassistant/components/alarm_control_panel/simplisafe.py @@ -1,5 +1,5 @@ """ -Interfaces with SimpliSafe alarm control panel. +This platform provides alarm control functionality for SimpliSafe. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.simplisafe/ @@ -7,86 +7,44 @@ https://home-assistant.io/components/alarm_control_panel.simplisafe/ import logging import re -import voluptuous as vol - -from homeassistant.components.alarm_control_panel import ( - PLATFORM_SCHEMA, AlarmControlPanel) +from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.simplisafe.const import ( + DATA_CLIENT, DOMAIN, TOPIC_UPDATE) from homeassistant.const import ( - CONF_CODE, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) -from homeassistant.helpers import aiohttp_client, config_validation as cv -from homeassistant.util.json import load_json, save_json - -REQUIREMENTS = ['simplisafe-python==3.1.2'] + CONF_CODE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) -ATTR_ALARM_ACTIVE = "alarm_active" -ATTR_TEMPERATURE = "temperature" - -DATA_FILE = '.simplisafe' - -DEFAULT_NAME = 'SimpliSafe' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_CODE): cv.string, -}) +ATTR_ALARM_ACTIVE = 'alarm_active' +ATTR_TEMPERATURE = 'temperature' async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): - """Set up the SimpliSafe platform.""" - from simplipy import API - from simplipy.errors import SimplipyError + """Set up a SimpliSafe alarm control panel based on existing config.""" + pass - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - name = config.get(CONF_NAME) - code = config.get(CONF_CODE) - websession = aiohttp_client.async_get_clientsession(hass) - - config_data = await hass.async_add_executor_job( - load_json, hass.config.path(DATA_FILE)) - - try: - if config_data: - try: - simplisafe = await API.login_via_token( - config_data['refresh_token'], websession) - _LOGGER.debug('Logging in with refresh token') - except SimplipyError: - _LOGGER.info('Refresh token expired; attempting credentials') - simplisafe = await API.login_via_credentials( - username, password, websession) - else: - simplisafe = await API.login_via_credentials( - username, password, websession) - _LOGGER.debug('Logging in with credentials') - except SimplipyError as err: - _LOGGER.error("There was an error during setup: %s", err) - return - - config_data = {'refresh_token': simplisafe.refresh_token} - await hass.async_add_executor_job( - save_json, hass.config.path(DATA_FILE), config_data) - - systems = await simplisafe.get_systems() - async_add_entities( - [SimpliSafeAlarm(system, name, code) for system in systems], True) +async def async_setup_entry(hass, entry, async_add_entities): + """Set up a SimpliSafe alarm control panel based on a config entry.""" + systems = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] + async_add_entities([ + SimpliSafeAlarm(system, entry.data.get(CONF_CODE)) + for system in systems + ], True) class SimpliSafeAlarm(AlarmControlPanel): """Representation of a SimpliSafe alarm.""" - def __init__(self, system, name, code): + def __init__(self, system, code): """Initialize the SimpliSafe alarm.""" + self._async_unsub_dispatcher_connect = None self._attrs = {} - self._code = str(code) if code else None - self._name = name + self._code = code self._system = system self._state = None @@ -98,9 +56,7 @@ class SimpliSafeAlarm(AlarmControlPanel): @property def name(self): """Return the name of the device.""" - if self._name: - return self._name - return 'Alarm {}'.format(self._system.system_id) + return self._system.address @property def code_format(self): @@ -128,6 +84,21 @@ class SimpliSafeAlarm(AlarmControlPanel): _LOGGER.warning("Wrong code entered for %s", state) return check + async def async_added_to_hass(self): + """Register callbacks.""" + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, TOPIC_UPDATE, update) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() + async def async_alarm_disarm(self, code=None): """Send disarm command.""" if not self._validate_code(code, 'disarming'): @@ -151,22 +122,24 @@ class SimpliSafeAlarm(AlarmControlPanel): async def async_update(self): """Update alarm status.""" - await self._system.update() + from simplipy.system import SystemStates - if self._system.state == self._system.SystemStates.off: - self._state = STATE_ALARM_DISARMED - elif self._system.state in ( - self._system.SystemStates.home, - self._system.SystemStates.home_count): - self._state = STATE_ALARM_ARMED_HOME - elif self._system.state in ( - self._system.SystemStates.away, - self._system.SystemStates.away_count, - self._system.SystemStates.exit_delay): - self._state = STATE_ALARM_ARMED_AWAY - else: - self._state = None + await self._system.update() self._attrs[ATTR_ALARM_ACTIVE] = self._system.alarm_going_off if self._system.temperature: self._attrs[ATTR_TEMPERATURE] = self._system.temperature + + if self._system.state == SystemStates.error: + return + + if self._system.state == SystemStates.off: + self._state = STATE_ALARM_DISARMED + elif self._system.state in (SystemStates.home, + SystemStates.home_count): + self._state = STATE_ALARM_ARMED_HOME + elif self._system.state in (SystemStates.away, SystemStates.away_count, + SystemStates.exit_delay): + self._state = STATE_ALARM_ARMED_AWAY + else: + self._state = None diff --git a/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant/components/alarm_control_panel/totalconnect.py index f594a798dce..2989bb1be37 100644 --- a/homeassistant/components/alarm_control_panel/totalconnect.py +++ b/homeassistant/components/alarm_control_panel/totalconnect.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_CUSTOM_BYPASS) -REQUIREMENTS = ['total_connect_client==0.18'] +REQUIREMENTS = ['total_connect_client==0.20'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/apcupsd.py b/homeassistant/components/apcupsd.py index 8808cee79a3..79b88378169 100644 --- a/homeassistant/components/apcupsd.py +++ b/homeassistant/components/apcupsd.py @@ -49,10 +49,9 @@ def setup(hass, config): # It doesn't really matter why we're not able to get the status, just that # we can't. - # pylint: disable=broad-except try: DATA.update(no_throttle=True) - except Exception: + except Exception: # pylint: disable=broad-except _LOGGER.exception("Failure while testing APCUPSd status retrieval.") return False return True diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py index 015e1e0d1fc..f7d9f012f65 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -16,7 +16,7 @@ from homeassistant.const import ( from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.dispatcher import dispatcher_send -REQUIREMENTS = ['pyarlo==0.2.0'] +REQUIREMENTS = ['pyarlo==0.2.2'] _LOGGER = logging.getLogger(__name__) @@ -81,7 +81,7 @@ def setup(hass, config): def hub_refresh(event_time): """Call ArloHub to refresh information.""" - _LOGGER.info("Updating Arlo Hub component") + _LOGGER.debug("Updating Arlo Hub component") hass.data[DATA_ARLO].update(update_cameras=True, update_base_station=True) dispatcher_send(hass, SIGNAL_UPDATE_ARLO) diff --git a/homeassistant/components/asterisk_mbox.py b/homeassistant/components/asterisk_mbox.py index 0907e48b256..406774d5fad 100644 --- a/homeassistant/components/asterisk_mbox.py +++ b/homeassistant/components/asterisk_mbox.py @@ -31,7 +31,7 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_PORT): int, + vol.Required(CONF_PORT): cv.port, }), }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/august.py b/homeassistant/components/august.py index 5f268a95f5d..850d972c373 100644 --- a/homeassistant/components/august.py +++ b/homeassistant/components/august.py @@ -4,7 +4,6 @@ Support for August devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/august/ """ - import logging from datetime import timedelta @@ -124,6 +123,7 @@ def setup_august(hass, config, api, authenticator): return True if state == AuthenticationState.BAD_PASSWORD: + _LOGGER.error("Invalid password provided") return False if state == AuthenticationState.REQUIRES_VALIDATION: request_configuration(hass, config, api, authenticator) @@ -165,6 +165,7 @@ class AugustData: self._doorbell_detail_by_id = {} self._lock_status_by_id = {} self._lock_detail_by_id = {} + self._door_state_by_id = {} self._activities_by_id = {} @property @@ -184,6 +185,7 @@ class AugustData: def get_device_activities(self, device_id, *activity_types): """Return a list of activities.""" + _LOGGER.debug("Getting device activities") self._update_device_activities() activities = self._activities_by_id.get(device_id, []) @@ -199,6 +201,7 @@ class AugustData: @Throttle(MIN_TIME_BETWEEN_UPDATES) def _update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT): """Update data object with latest from August API.""" + _LOGGER.debug("Updating device activities") for house_id in self.house_ids: activities = self._api.get_house_activities(self._access_token, house_id, @@ -218,14 +221,21 @@ class AugustData: def _update_doorbells(self): detail_by_id = {} + _LOGGER.debug("Start retrieving doorbell details") for doorbell in self._doorbells: + _LOGGER.debug("Updating status for %s", + doorbell.device_name) detail_by_id[doorbell.device_id] = self._api.get_doorbell_detail( self._access_token, doorbell.device_id) + _LOGGER.debug("Completed retrieving doorbell details") self._doorbell_detail_by_id = detail_by_id def get_lock_status(self, lock_id): - """Return lock status.""" + """Return status if the door is locked or unlocked. + + This is status for the lock itself. + """ self._update_locks() return self._lock_status_by_id.get(lock_id) @@ -234,17 +244,43 @@ class AugustData: self._update_locks() return self._lock_detail_by_id.get(lock_id) + def get_door_state(self, lock_id): + """Return status if the door is open or closed. + + This is the status from the door sensor. + """ + self._update_doors() + return self._door_state_by_id.get(lock_id) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_doors(self): + state_by_id = {} + + _LOGGER.debug("Start retrieving door status") + for lock in self._locks: + _LOGGER.debug("Updating status for %s", + lock.device_name) + state_by_id[lock.device_id] = self._api.get_lock_door_status( + self._access_token, lock.device_id) + + _LOGGER.debug("Completed retrieving door status") + self._door_state_by_id = state_by_id + @Throttle(MIN_TIME_BETWEEN_UPDATES) def _update_locks(self): status_by_id = {} detail_by_id = {} + _LOGGER.debug("Start retrieving locks status") for lock in self._locks: + _LOGGER.debug("Updating status for %s", + lock.device_name) status_by_id[lock.device_id] = self._api.get_lock_status( self._access_token, lock.device_id) detail_by_id[lock.device_id] = self._api.get_lock_detail( self._access_token, lock.device_id) + _LOGGER.debug("Completed retrieving locks status") self._lock_status_by_id = status_by_id self._lock_detail_by_id = detail_by_id diff --git a/homeassistant/components/auth/.translations/ro.json b/homeassistant/components/auth/.translations/ro.json new file mode 100644 index 00000000000..19f9ec10c73 --- /dev/null +++ b/homeassistant/components/auth/.translations/ro.json @@ -0,0 +1,34 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Nu sunt disponibile servicii de notificare." + }, + "error": { + "invalid_code": "Cod invalid, va rugam incercati din nou." + }, + "step": { + "init": { + "description": "Selecta\u021bi unul dintre serviciile de notificare:", + "title": "Configura\u021bi o parol\u0103 unic\u0103 livrat\u0103 de o component\u0103 de notificare" + }, + "setup": { + "description": "O parol\u0103 unic\u0103 a fost trimis\u0103 prin **notify.{notify_service}**. Introduce\u021bi parola mai jos:", + "title": "Verifica\u021bi configurarea" + } + }, + "title": "Notifica\u021bi o parol\u0103 unic\u0103" + }, + "totp": { + "error": { + "invalid_code": "Cod invalid, va rugam incercati din nou. Dac\u0103 primi\u021bi aceast\u0103 eroare \u00een mod consecvent, asigura\u021bi-v\u0103 c\u0103 ceasul sistemului dvs. Home Assistant este corect." + }, + "step": { + "init": { + "title": "Configura\u021bi autentificarea cu doi factori utiliz\u00e2nd TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/automation/geo_location.py b/homeassistant/components/automation/geo_location.py new file mode 100644 index 00000000000..b2c9a9c093a --- /dev/null +++ b/homeassistant/components/automation/geo_location.py @@ -0,0 +1,74 @@ +""" +Offer geo location automation rules. + +For more details about this automation trigger, please refer to the +documentation at +https://home-assistant.io/docs/automation/trigger/#geo-location-trigger +""" +import voluptuous as vol + +from homeassistant.components.geo_location import DOMAIN +from homeassistant.core import callback +from homeassistant.const import ( + CONF_EVENT, CONF_PLATFORM, CONF_SOURCE, CONF_ZONE, EVENT_STATE_CHANGED) +from homeassistant.helpers import ( + condition, config_validation as cv) +from homeassistant.helpers.config_validation import entity_domain + +EVENT_ENTER = 'enter' +EVENT_LEAVE = 'leave' +DEFAULT_EVENT = EVENT_ENTER + +TRIGGER_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'geo_location', + vol.Required(CONF_SOURCE): cv.string, + vol.Required(CONF_ZONE): entity_domain('zone'), + vol.Required(CONF_EVENT, default=DEFAULT_EVENT): + vol.Any(EVENT_ENTER, EVENT_LEAVE), +}) + + +def source_match(state, source): + """Check if the state matches the provided source.""" + return state and state.attributes.get('source') == source + + +async def async_trigger(hass, config, action): + """Listen for state changes based on configuration.""" + source = config.get(CONF_SOURCE).lower() + zone_entity_id = config.get(CONF_ZONE) + trigger_event = config.get(CONF_EVENT) + + @callback + def state_change_listener(event): + """Handle specific state changes.""" + # Skip if the event is not a geo_location entity. + if not event.data.get('entity_id').startswith(DOMAIN): + return + # Skip if the event's source does not match the trigger's source. + from_state = event.data.get('old_state') + to_state = event.data.get('new_state') + if not source_match(from_state, source) \ + and not source_match(to_state, source): + return + + zone_state = hass.states.get(zone_entity_id) + from_match = condition.zone(hass, zone_state, from_state) + to_match = condition.zone(hass, zone_state, to_state) + + # pylint: disable=too-many-boolean-expressions + if trigger_event == EVENT_ENTER and not from_match and to_match or \ + trigger_event == EVENT_LEAVE and from_match and not to_match: + hass.async_run_job(action({ + 'trigger': { + 'platform': 'geo_location', + 'source': source, + 'entity_id': event.data.get('entity_id'), + 'from_state': from_state, + 'to_state': to_state, + 'zone': zone_state, + 'event': trigger_event, + }, + }, context=event.context)) + + return hass.bus.async_listen(EVENT_STATE_CHANGED, state_change_listener) diff --git a/homeassistant/components/automation/webhook.py b/homeassistant/components/automation/webhook.py index 2c9c331cdc5..345b0fe3249 100644 --- a/homeassistant/components/automation/webhook.py +++ b/homeassistant/components/automation/webhook.py @@ -11,13 +11,12 @@ from aiohttp import hdrs import voluptuous as vol from homeassistant.core import callback -from homeassistant.const import CONF_PLATFORM +from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID import homeassistant.helpers.config_validation as cv DEPENDENCIES = ('webhook',) _LOGGER = logging.getLogger(__name__) -CONF_WEBHOOK_ID = 'webhook_id' TRIGGER_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'webhook', diff --git a/homeassistant/components/axis.py b/homeassistant/components/axis.py index 71894364f91..63fce8a74ee 100644 --- a/homeassistant/components/axis.py +++ b/homeassistant/components/axis.py @@ -38,6 +38,7 @@ AXIS_INCLUDE = EVENT_TYPES + PLATFORMS AXIS_DEFAULT_HOST = '192.168.0.90' AXIS_DEFAULT_USERNAME = 'root' AXIS_DEFAULT_PASSWORD = 'pass' +DEFAULT_PORT = 80 DEVICE_SCHEMA = vol.Schema({ vol.Required(CONF_INCLUDE): @@ -47,7 +48,7 @@ DEVICE_SCHEMA = vol.Schema({ vol.Optional(CONF_USERNAME, default=AXIS_DEFAULT_USERNAME): cv.string, vol.Optional(CONF_PASSWORD, default=AXIS_DEFAULT_PASSWORD): cv.string, vol.Optional(CONF_TRIGGER_TIME, default=0): cv.positive_int, - vol.Optional(CONF_PORT, default=80): cv.positive_int, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(ATTR_LOCATION, default=''): cv.string, }) diff --git a/homeassistant/components/binary_sensor/august.py b/homeassistant/components/binary_sensor/august.py index 7f5da390906..55b31a6da5f 100644 --- a/homeassistant/components/binary_sensor/august.py +++ b/homeassistant/components/binary_sensor/august.py @@ -4,16 +4,26 @@ Support for August binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.august/ """ +import logging from datetime import timedelta, datetime from homeassistant.components.august import DATA_AUGUST from homeassistant.components.binary_sensor import (BinarySensorDevice) +_LOGGER = logging.getLogger(__name__) + DEPENDENCIES = ['august'] SCAN_INTERVAL = timedelta(seconds=5) +def _retrieve_door_state(data, lock): + """Get the latest state of the DoorSense sensor.""" + from august.lock import LockDoorStatus + doorstate = data.get_door_state(lock.device_id) + return doorstate == LockDoorStatus.OPEN + + def _retrieve_online_state(data, doorbell): """Get the latest state of the sensor.""" detail = data.get_doorbell_detail(doorbell.device_id) @@ -46,7 +56,11 @@ def _activity_time_based_state(data, doorbell, activity_types): # Sensor types: Name, device_class, state_provider -SENSOR_TYPES = { +SENSOR_TYPES_DOOR = { + 'door_open': ['Open', 'door', _retrieve_door_state], +} + +SENSOR_TYPES_DOORBELL = { 'doorbell_ding': ['Ding', 'occupancy', _retrieve_ding_state], 'doorbell_motion': ['Motion', 'motion', _retrieve_motion_state], 'doorbell_online': ['Online', 'connectivity', _retrieve_online_state], @@ -58,14 +72,78 @@ def setup_platform(hass, config, add_entities, discovery_info=None): data = hass.data[DATA_AUGUST] devices = [] + from august.lock import LockDoorStatus + for door in data.locks: + for sensor_type in SENSOR_TYPES_DOOR: + state_provider = SENSOR_TYPES_DOOR[sensor_type][2] + if state_provider(data, door) is LockDoorStatus.UNKNOWN: + _LOGGER.debug( + "Not adding sensor class %s for lock %s ", + SENSOR_TYPES_DOOR[sensor_type][1], door.device_name + ) + continue + + _LOGGER.debug( + "Adding sensor class %s for %s", + SENSOR_TYPES_DOOR[sensor_type][1], door.device_name + ) + devices.append(AugustDoorBinarySensor(data, sensor_type, door)) + for doorbell in data.doorbells: - for sensor_type in SENSOR_TYPES: - devices.append(AugustBinarySensor(data, sensor_type, doorbell)) + for sensor_type in SENSOR_TYPES_DOORBELL: + _LOGGER.debug("Adding doorbell sensor class %s for %s", + SENSOR_TYPES_DOORBELL[sensor_type][1], + doorbell.device_name) + devices.append( + AugustDoorbellBinarySensor(data, sensor_type, + doorbell) + ) add_entities(devices, True) -class AugustBinarySensor(BinarySensorDevice): +class AugustDoorBinarySensor(BinarySensorDevice): + """Representation of an August Door binary sensor.""" + + def __init__(self, data, sensor_type, door): + """Initialize the sensor.""" + self._data = data + self._sensor_type = sensor_type + self._door = door + self._state = None + self._available = False + + @property + def available(self): + """Return the availability of this sensor.""" + return self._available + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return SENSOR_TYPES_DOOR[self._sensor_type][1] + + @property + def name(self): + """Return the name of the binary sensor.""" + return "{} {}".format(self._door.device_name, + SENSOR_TYPES_DOOR[self._sensor_type][0]) + + def update(self): + """Get the latest state of the sensor.""" + state_provider = SENSOR_TYPES_DOOR[self._sensor_type][2] + self._state = state_provider(self._data, self._door) + + from august.lock import LockDoorStatus + self._available = self._state != LockDoorStatus.UNKNOWN + + +class AugustDoorbellBinarySensor(BinarySensorDevice): """Representation of an August binary sensor.""" def __init__(self, data, sensor_type, doorbell): @@ -83,15 +161,15 @@ class AugustBinarySensor(BinarySensorDevice): @property def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" - return SENSOR_TYPES[self._sensor_type][1] + return SENSOR_TYPES_DOORBELL[self._sensor_type][1] @property def name(self): """Return the name of the binary sensor.""" return "{} {}".format(self._doorbell.device_name, - SENSOR_TYPES[self._sensor_type][0]) + SENSOR_TYPES_DOORBELL[self._sensor_type][0]) def update(self): """Get the latest state of the sensor.""" - state_provider = SENSOR_TYPES[self._sensor_type][2] + state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][2] self._state = state_provider(self._data, self._doorbell) diff --git a/homeassistant/components/binary_sensor/bloomsky.py b/homeassistant/components/binary_sensor/bloomsky.py index ecffb3accf3..971941f4dd6 100644 --- a/homeassistant/components/binary_sensor/bloomsky.py +++ b/homeassistant/components/binary_sensor/bloomsky.py @@ -50,6 +50,12 @@ class BloomSkySensor(BinarySensorDevice): self._sensor_name = sensor_name self._name = '{} {}'.format(device['DeviceName'], sensor_name) self._state = None + self._unique_id = '{}-{}'.format(self._device_id, self._sensor_name) + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id @property def name(self): diff --git a/homeassistant/components/binary_sensor/bmw_connected_drive.py b/homeassistant/components/binary_sensor/bmw_connected_drive.py index 3fe8136c93b..f8855b2e28b 100644 --- a/homeassistant/components/binary_sensor/bmw_connected_drive.py +++ b/homeassistant/components/binary_sensor/bmw_connected_drive.py @@ -8,6 +8,7 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.const import LENGTH_KILOMETERS DEPENDENCIES = ['bmw_connected_drive'] @@ -117,7 +118,8 @@ class BMWConnectedDriveSensor(BinarySensorDevice): result['lights_parking'] = vehicle_state.parking_lights.value elif self._attribute == 'condition_based_services': for report in vehicle_state.condition_based_services: - result.update(self._format_cbs_report(report)) + result.update( + self._format_cbs_report(report)) elif self._attribute == 'check_control_messages': check_control_messages = vehicle_state.check_control_messages if not check_control_messages: @@ -175,8 +177,7 @@ class BMWConnectedDriveSensor(BinarySensorDevice): self._state = (vehicle_state._attributes['connectionStatus'] == 'CONNECTED') - @staticmethod - def _format_cbs_report(report): + def _format_cbs_report(self, report): result = {} service_type = report.service_type.lower().replace('_', ' ') result['{} status'.format(service_type)] = report.state.value @@ -184,8 +185,10 @@ class BMWConnectedDriveSensor(BinarySensorDevice): result['{} date'.format(service_type)] = \ report.due_date.strftime('%Y-%m-%d') if report.due_distance is not None: - result['{} distance'.format(service_type)] = \ - '{} km'.format(report.due_distance) + distance = round(self.hass.config.units.length( + report.due_distance, LENGTH_KILOMETERS)) + result['{} distance'.format(service_type)] = '{} {}'.format( + distance, self.hass.config.units.length_unit) return result def update_callback(self): diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index d0707b0f067..a89d5d1c945 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -69,7 +69,7 @@ def async_add_entities_discovery(hass, discovery_info, async_add_entities): entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] - entities.append(KNXBinarySensor(hass, device)) + entities.append(KNXBinarySensor(device)) async_add_entities(entities) @@ -87,7 +87,7 @@ def async_add_entities_config(hass, config, async_add_entities): reset_after=config.get(CONF_RESET_AFTER)) hass.data[DATA_KNX].xknx.devices.add(binary_sensor) - entity = KNXBinarySensor(hass, binary_sensor) + entity = KNXBinarySensor(binary_sensor) automations = config.get(CONF_AUTOMATION) if automations is not None: for automation in automations: @@ -103,11 +103,9 @@ def async_add_entities_config(hass, config, async_add_entities): class KNXBinarySensor(BinarySensorDevice): """Representation of a KNX binary sensor.""" - def __init__(self, hass, device): + def __init__(self, device): """Initialize of KNX binary sensor.""" self.device = device - self.hass = hass - self.async_register_callbacks() self.automations = [] @callback @@ -118,6 +116,10 @@ class KNXBinarySensor(BinarySensorDevice): await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) + async def async_added_to_hass(self): + """Store register state change callback.""" + self.async_register_callbacks() + @property def name(self): """Return the name of the KNX device.""" diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index baaf6a9a567..beaeb9ce21b 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -15,19 +15,21 @@ from homeassistant.components.binary_sensor import ( BinarySensorDevice, DEVICE_CLASSES_SCHEMA) from homeassistant.const import ( CONF_FORCE_UPDATE, CONF_NAME, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_ON, - CONF_PAYLOAD_OFF, CONF_DEVICE_CLASS) + CONF_PAYLOAD_OFF, CONF_DEVICE_CLASS, CONF_DEVICE) from homeassistant.components.mqtt import ( ATTR_DISCOVERY_HASH, CONF_STATE_TOPIC, CONF_AVAILABILITY_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, - MqttAvailability, MqttDiscoveryUpdate) + MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo) from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect +import homeassistant.helpers.event as evt from homeassistant.helpers.typing import HomeAssistantType, ConfigType _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'MQTT Binary sensor' +CONF_OFF_DELAY = 'off_delay' CONF_UNIQUE_ID = 'unique_id' DEFAULT_PAYLOAD_OFF = 'OFF' DEFAULT_PAYLOAD_ON = 'ON' @@ -41,9 +43,12 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, + vol.Optional(CONF_OFF_DELAY): + vol.All(vol.Coerce(int), vol.Range(min=0)), # Integrations shouldn't never expose unique_id through configuration # this here is an exception because MQTT is a msg transport, not a protocol vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @@ -80,28 +85,32 @@ async def _async_setup_entity(hass, config, async_add_entities, config.get(CONF_DEVICE_CLASS), config.get(CONF_QOS), config.get(CONF_FORCE_UPDATE), + config.get(CONF_OFF_DELAY), config.get(CONF_PAYLOAD_ON), config.get(CONF_PAYLOAD_OFF), config.get(CONF_PAYLOAD_AVAILABLE), config.get(CONF_PAYLOAD_NOT_AVAILABLE), value_template, config.get(CONF_UNIQUE_ID), + config.get(CONF_DEVICE), discovery_hash, )]) class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, - BinarySensorDevice): + MqttEntityDeviceInfo, BinarySensorDevice): """Representation a binary sensor that is updated by MQTT.""" def __init__(self, name, state_topic, availability_topic, device_class, - qos, force_update, payload_on, payload_off, payload_available, - payload_not_available, value_template, - unique_id: Optional[str], discovery_hash): + qos, force_update, off_delay, payload_on, payload_off, + payload_available, payload_not_available, value_template, + unique_id: Optional[str], device_config: Optional[ConfigType], + discovery_hash): """Initialize the MQTT binary sensor.""" MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) MqttDiscoveryUpdate.__init__(self, discovery_hash) + MqttEntityDeviceInfo.__init__(self, device_config) self._name = name self._state = None self._state_topic = state_topic @@ -110,9 +119,11 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, self._payload_off = payload_off self._qos = qos self._force_update = force_update + self._off_delay = off_delay self._template = value_template self._unique_id = unique_id self._discovery_hash = discovery_hash + self._delay_listener = None async def async_added_to_hass(self): """Subscribe mqtt events.""" @@ -135,6 +146,20 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, self._name, self._state_topic) return + if (self._state and self._off_delay is not None): + @callback + def off_delay_listener(now): + """Switch device off after a delay.""" + self._delay_listener = None + self._state = False + self.async_schedule_update_ha_state() + + if self._delay_listener is not None: + self._delay_listener() + + self._delay_listener = evt.async_call_later( + self.hass, self._off_delay, off_delay_listener) + self.async_schedule_update_ha_state() await mqtt.async_subscribe( diff --git a/homeassistant/components/binary_sensor/octoprint.py b/homeassistant/components/binary_sensor/octoprint.py index 3dd1ee2be8c..285495c03a0 100644 --- a/homeassistant/components/binary_sensor/octoprint.py +++ b/homeassistant/components/binary_sensor/octoprint.py @@ -7,45 +7,33 @@ https://home-assistant.io/components/binary_sensor.octoprint/ import logging import requests -import voluptuous as vol -from homeassistant.const import CONF_NAME, CONF_MONITORED_CONDITIONS -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) -import homeassistant.helpers.config_validation as cv +from homeassistant.components.octoprint import (BINARY_SENSOR_TYPES, + DOMAIN as COMPONENT_DOMAIN) +from homeassistant.components.binary_sensor import BinarySensorDevice _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['octoprint'] -DOMAIN = "octoprint" -DEFAULT_NAME = 'OctoPrint' - -SENSOR_TYPES = { - # API Endpoint, Group, Key, unit - 'Printing': ['printer', 'state', 'printing', None], - 'Printing Error': ['printer', 'state', 'error', None] -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, -}) def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the available OctoPrint binary sensors.""" - octoprint_api = hass.data[DOMAIN]["api"] - name = config.get(CONF_NAME) - monitored_conditions = config.get( - CONF_MONITORED_CONDITIONS, SENSOR_TYPES.keys()) + if discovery_info is None: + return + + name = discovery_info['name'] + base_url = discovery_info['base_url'] + monitored_conditions = discovery_info['sensors'] + octoprint_api = hass.data[COMPONENT_DOMAIN][base_url] devices = [] for octo_type in monitored_conditions: new_sensor = OctoPrintBinarySensor( - octoprint_api, octo_type, SENSOR_TYPES[octo_type][2], - name, SENSOR_TYPES[octo_type][3], SENSOR_TYPES[octo_type][0], - SENSOR_TYPES[octo_type][1], 'flags') + octoprint_api, octo_type, BINARY_SENSOR_TYPES[octo_type][2], + name, BINARY_SENSOR_TYPES[octo_type][3], + BINARY_SENSOR_TYPES[octo_type][0], + BINARY_SENSOR_TYPES[octo_type][1], 'flags') devices.append(new_sensor) add_entities(devices, True) diff --git a/homeassistant/components/binary_sensor/opentherm_gw.py b/homeassistant/components/binary_sensor/opentherm_gw.py new file mode 100644 index 00000000000..8c5ff8c44d1 --- /dev/null +++ b/homeassistant/components/binary_sensor/opentherm_gw.py @@ -0,0 +1,145 @@ +""" +Support for OpenTherm Gateway binary sensors. + +For more details about this platform, please refer to the documentation at +http://home-assistant.io/components/binary_sensor.opentherm_gw/ +""" +import logging + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, ENTITY_ID_FORMAT) +from homeassistant.components.opentherm_gw import ( + DATA_GW_VARS, DATA_OPENTHERM_GW, SIGNAL_OPENTHERM_GW_UPDATE) +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import async_generate_entity_id + +DEVICE_CLASS_COLD = 'cold' +DEVICE_CLASS_HEAT = 'heat' +DEVICE_CLASS_PROBLEM = 'problem' + +DEPENDENCIES = ['opentherm_gw'] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the OpenTherm Gateway binary sensors.""" + if discovery_info is None: + return + gw_vars = hass.data[DATA_OPENTHERM_GW][DATA_GW_VARS] + sensor_info = { + # [device_class, friendly_name] + gw_vars.DATA_MASTER_CH_ENABLED: [ + None, "Thermostat Central Heating Enabled"], + gw_vars.DATA_MASTER_DHW_ENABLED: [ + None, "Thermostat Hot Water Enabled"], + gw_vars.DATA_MASTER_COOLING_ENABLED: [ + None, "Thermostat Cooling Enabled"], + gw_vars.DATA_MASTER_OTC_ENABLED: [ + None, "Thermostat Outside Temperature Correction Enabled"], + gw_vars.DATA_MASTER_CH2_ENABLED: [ + None, "Thermostat Central Heating 2 Enabled"], + gw_vars.DATA_SLAVE_FAULT_IND: [ + DEVICE_CLASS_PROBLEM, "Boiler Fault Indication"], + gw_vars.DATA_SLAVE_CH_ACTIVE: [ + DEVICE_CLASS_HEAT, "Boiler Central Heating Status"], + gw_vars.DATA_SLAVE_DHW_ACTIVE: [ + DEVICE_CLASS_HEAT, "Boiler Hot Water Status"], + gw_vars.DATA_SLAVE_FLAME_ON: [ + DEVICE_CLASS_HEAT, "Boiler Flame Status"], + gw_vars.DATA_SLAVE_COOLING_ACTIVE: [ + DEVICE_CLASS_COLD, "Boiler Cooling Status"], + gw_vars.DATA_SLAVE_CH2_ACTIVE: [ + DEVICE_CLASS_HEAT, "Boiler Central Heating 2 Status"], + gw_vars.DATA_SLAVE_DIAG_IND: [ + DEVICE_CLASS_PROBLEM, "Boiler Diagnostics Indication"], + gw_vars.DATA_SLAVE_DHW_PRESENT: [None, "Boiler Hot Water Present"], + gw_vars.DATA_SLAVE_CONTROL_TYPE: [None, "Boiler Control Type"], + gw_vars.DATA_SLAVE_COOLING_SUPPORTED: [None, "Boiler Cooling Support"], + gw_vars.DATA_SLAVE_DHW_CONFIG: [ + None, "Boiler Hot Water Configuration"], + gw_vars.DATA_SLAVE_MASTER_LOW_OFF_PUMP: [ + None, "Boiler Pump Commands Support"], + gw_vars.DATA_SLAVE_CH2_PRESENT: [ + None, "Boiler Central Heating 2 Present"], + gw_vars.DATA_SLAVE_SERVICE_REQ: [ + DEVICE_CLASS_PROBLEM, "Boiler Service Required"], + gw_vars.DATA_SLAVE_REMOTE_RESET: [None, "Boiler Remote Reset Support"], + gw_vars.DATA_SLAVE_LOW_WATER_PRESS: [ + DEVICE_CLASS_PROBLEM, "Boiler Low Water Pressure"], + gw_vars.DATA_SLAVE_GAS_FAULT: [ + DEVICE_CLASS_PROBLEM, "Boiler Gas Fault"], + gw_vars.DATA_SLAVE_AIR_PRESS_FAULT: [ + DEVICE_CLASS_PROBLEM, "Boiler Air Pressure Fault"], + gw_vars.DATA_SLAVE_WATER_OVERTEMP: [ + DEVICE_CLASS_PROBLEM, "Boiler Water Overtemperature"], + gw_vars.DATA_REMOTE_TRANSFER_DHW: [ + None, "Remote Hot Water Setpoint Transfer Support"], + gw_vars.DATA_REMOTE_TRANSFER_MAX_CH: [ + None, "Remote Maximum Central Heating Setpoint Write Support"], + gw_vars.DATA_REMOTE_RW_DHW: [ + None, "Remote Hot Water Setpoint Write Support"], + gw_vars.DATA_REMOTE_RW_MAX_CH: [ + None, "Remote Central Heating Setpoint Write Support"], + gw_vars.DATA_ROVRD_MAN_PRIO: [ + None, "Remote Override Manual Change Priority"], + gw_vars.DATA_ROVRD_AUTO_PRIO: [ + None, "Remote Override Program Change Priority"], + gw_vars.OTGW_GPIO_A_STATE: [None, "Gateway GPIO A State"], + gw_vars.OTGW_GPIO_B_STATE: [None, "Gateway GPIO B State"], + gw_vars.OTGW_IGNORE_TRANSITIONS: [None, "Gateway Ignore Transitions"], + gw_vars.OTGW_OVRD_HB: [None, "Gateway Override High Byte"], + } + sensors = [] + for var in discovery_info: + device_class = sensor_info[var][0] + friendly_name = sensor_info[var][1] + entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, var, hass=hass) + sensors.append(OpenThermBinarySensor(entity_id, var, device_class, + friendly_name)) + async_add_entities(sensors) + + +class OpenThermBinarySensor(BinarySensorDevice): + """Represent an OpenTherm Gateway binary sensor.""" + + def __init__(self, entity_id, var, device_class, friendly_name): + """Initialize the binary sensor.""" + self.entity_id = entity_id + self._var = var + self._state = None + self._device_class = device_class + self._friendly_name = friendly_name + + async def async_added_to_hass(self): + """Subscribe to updates from the component.""" + _LOGGER.debug( + "Added OpenTherm Gateway binary sensor %s", self._friendly_name) + async_dispatcher_connect(self.hass, SIGNAL_OPENTHERM_GW_UPDATE, + self.receive_report) + + async def receive_report(self, status): + """Handle status updates from the component.""" + self._state = bool(status.get(self._var)) + self.async_schedule_update_ha_state() + + @property + def name(self): + """Return the friendly name.""" + return self._friendly_name + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the class of this device.""" + return self._device_class + + @property + def should_poll(self): + """Return False because entity pushes its state.""" + return False diff --git a/homeassistant/components/binary_sensor/openuv.py b/homeassistant/components/binary_sensor/openuv.py index bd6e4d1d5dc..3e9bb0b0bc3 100644 --- a/homeassistant/components/binary_sensor/openuv.py +++ b/homeassistant/components/binary_sensor/openuv.py @@ -50,12 +50,12 @@ class OpenUvBinarySensor(OpenUvEntity, BinarySensorDevice): """Initialize the sensor.""" super().__init__(openuv) + self._async_unsub_dispatcher_connect = None self._entry_id = entry_id self._icon = icon self._latitude = openuv.client.latitude self._longitude = openuv.client.longitude self._name = name - self._dispatch_remove = None self._sensor_type = sensor_type self._state = None @@ -80,16 +80,20 @@ class OpenUvBinarySensor(OpenUvEntity, BinarySensorDevice): return '{0}_{1}_{2}'.format( self._latitude, self._longitude, self._sensor_type) - @callback - def _update_data(self): - """Update the state.""" - self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): """Register callbacks.""" - self._dispatch_remove = async_dispatcher_connect( - self.hass, TOPIC_UPDATE, self._update_data) - self.async_on_remove(self._dispatch_remove) + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, TOPIC_UPDATE, update) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() async def async_update(self): """Update the state.""" diff --git a/homeassistant/components/binary_sensor/ping.py b/homeassistant/components/binary_sensor/ping.py index f12957e6129..4c597dd63e1 100644 --- a/homeassistant/components/binary_sensor/ping.py +++ b/homeassistant/components/binary_sensor/ping.py @@ -4,19 +4,18 @@ Tracks the latency of a host by sending ICMP echo requests (ping). For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.ping/ """ -import asyncio -from datetime import timedelta import logging -import re import subprocess +import re import sys +from datetime import timedelta import voluptuous as vol -from homeassistant.components.binary_sensor import ( - PLATFORM_SCHEMA, BinarySensorDevice) -from homeassistant.const import CONF_HOST, CONF_NAME import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import CONF_NAME, CONF_HOST _LOGGER = logging.getLogger(__name__) @@ -49,14 +48,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): +def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Ping Binary sensor.""" name = config.get(CONF_NAME) host = config.get(CONF_HOST) count = config.get(CONF_PING_COUNT) - async_add_entities([PingBinarySensor(name, PingData(host, count))], True) + add_entities([PingBinarySensor(name, PingData(host, count))], True) class PingBinarySensor(BinarySensorDevice): @@ -93,9 +91,9 @@ class PingBinarySensor(BinarySensorDevice): ATTR_ROUND_TRIP_TIME_MIN: self.ping.data['min'], } - async def async_update(self): + def update(self): """Get the latest data.""" - await self.ping.update() + self.ping.update() class PingData: @@ -116,13 +114,12 @@ class PingData: 'ping', '-n', '-q', '-c', str(self._count), '-W1', self._ip_address] - async def ping(self): + def ping(self): """Send ICMP echo request and return details if success.""" - pinger = await asyncio.create_subprocess_shell( - ' '.join(self._ping_cmd), stdout=subprocess.PIPE, - stderr=subprocess.PIPE) + pinger = subprocess.Popen( + self._ping_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) try: - out = await pinger.communicate() + out = pinger.communicate() _LOGGER.debug("Output is %s", str(out)) if sys.platform == 'win32': match = WIN32_PING_MATCHER.search(str(out).split('\n')[-1]) @@ -131,8 +128,7 @@ class PingData: 'min': rtt_min, 'avg': rtt_avg, 'max': rtt_max, - 'mdev': '', - } + 'mdev': ''} if 'max/' not in str(out): match = PING_MATCHER_BUSYBOX.search(str(out).split('\n')[-1]) rtt_min, rtt_avg, rtt_max = match.groups() @@ -140,20 +136,18 @@ class PingData: 'min': rtt_min, 'avg': rtt_avg, 'max': rtt_max, - 'mdev': '', - } + 'mdev': ''} match = PING_MATCHER.search(str(out).split('\n')[-1]) rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups() return { 'min': rtt_min, 'avg': rtt_avg, 'max': rtt_max, - 'mdev': rtt_mdev, - } + 'mdev': rtt_mdev} except (subprocess.CalledProcessError, AttributeError): return False - async def update(self): + def update(self): """Retrieve the latest details from the host.""" - self.data = await self.ping() + self.data = self.ping() self.available = bool(self.data) diff --git a/homeassistant/components/binary_sensor/rflink.py b/homeassistant/components/binary_sensor/rflink.py new file mode 100644 index 00000000000..73b912d62da --- /dev/null +++ b/homeassistant/components/binary_sensor/rflink.py @@ -0,0 +1,105 @@ +""" +Support for Rflink binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.rflink/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.components.rflink import ( + CONF_ALIASES, CONF_DEVICES, RflinkDevice) +from homeassistant.const import ( + CONF_FORCE_UPDATE, CONF_NAME, CONF_DEVICE_CLASS) +import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.event as evt + +CONF_OFF_DELAY = 'off_delay' +DEFAULT_FORCE_UPDATE = False + +DEPENDENCIES = ['rflink'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICES, default={}): { + cv.string: vol.Schema({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): + cv.boolean, + vol.Optional(CONF_OFF_DELAY): cv.positive_int, + vol.Optional(CONF_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + }) + }, +}, extra=vol.ALLOW_EXTRA) + + +def devices_from_config(domain_config): + """Parse configuration and add Rflink sensor devices.""" + devices = [] + for device_id, config in domain_config[CONF_DEVICES].items(): + device = RflinkBinarySensor(device_id, **config) + devices.append(device) + + return devices + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Rflink platform.""" + async_add_entities(devices_from_config(config)) + + +class RflinkBinarySensor(RflinkDevice, BinarySensorDevice): + """Representation of an Rflink binary sensor.""" + + def __init__(self, device_id, device_class=None, + force_update=None, off_delay=None, + **kwargs): + """Handle sensor specific args and super init.""" + self._state = None + self._device_class = device_class + self._force_update = force_update + self._off_delay = off_delay + self._delay_listener = None + super().__init__(device_id, **kwargs) + + def _handle_event(self, event): + """Domain specific event handler.""" + command = event['command'] + if command == 'on': + self._state = True + elif command == 'off': + self._state = False + + if (self._state and self._off_delay is not None): + def off_delay_listener(now): + """Switch device off after a delay.""" + self._delay_listener = None + self._state = False + self.async_schedule_update_ha_state() + + if self._delay_listener is not None: + self._delay_listener() + self._delay_listener = evt.async_call_later( + self.hass, self._off_delay, off_delay_listener) + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._device_class + + @property + def force_update(self): + """Force update.""" + return self._force_update diff --git a/homeassistant/components/binary_sensor/ring.py b/homeassistant/components/binary_sensor/ring.py index 3945eb5c926..5a65917f40b 100644 --- a/homeassistant/components/binary_sensor/ring.py +++ b/homeassistant/components/binary_sensor/ring.py @@ -73,6 +73,7 @@ class RingBinarySensor(BinarySensorDevice): SENSOR_TYPES.get(self._sensor_type)[0]) self._device_class = SENSOR_TYPES.get(self._sensor_type)[2] self._state = None + self._unique_id = '{}-{}'.format(self._data.id, self._sensor_type) @property def name(self): @@ -89,6 +90,11 @@ class RingBinarySensor(BinarySensorDevice): """Return the class of the binary sensor.""" return self._device_class + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + @property def device_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/binary_sensor/trend.py b/homeassistant/components/binary_sensor/trend.py index 0b168e45b4d..d332c668703 100644 --- a/homeassistant/components/binary_sensor/trend.py +++ b/homeassistant/components/binary_sensor/trend.py @@ -22,7 +22,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.15.1'] +REQUIREMENTS = ['numpy==1.15.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/binary_sensor/workday.py b/homeassistant/components/binary_sensor/workday.py index 82b5e66629a..fc8207f83b7 100644 --- a/homeassistant/components/binary_sensor/workday.py +++ b/homeassistant/components/binary_sensor/workday.py @@ -14,15 +14,16 @@ from homeassistant.const import CONF_NAME, WEEKDAYS from homeassistant.components.binary_sensor import BinarySensorDevice import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['holidays==0.9.7'] +REQUIREMENTS = ['holidays==0.9.8'] _LOGGER = logging.getLogger(__name__) # List of all countries currently supported by holidays # There seems to be no way to get the list out at runtime ALL_COUNTRIES = [ - 'Argentina', 'AR', 'Australia', 'AU', 'Austria', 'AT', 'Belarus', 'BY' - 'Belgium', 'BE', 'Canada', 'CA', 'Colombia', 'CO', 'Czech', 'CZ', + 'Argentina', 'AR', 'Australia', 'AU', 'Austria', 'AT', + 'Brazil', 'BR', 'Belarus', 'BY', 'Belgium', 'BE', + 'Canada', 'CA', 'Colombia', 'CO', 'Croatia', 'HR', 'Czech', 'CZ', 'Denmark', 'DK', 'England', 'EuropeanCentralBank', 'ECB', 'TAR', 'Finland', 'FI', 'France', 'FRA', 'Germany', 'DE', 'Hungary', 'HU', 'India', 'IND', 'Ireland', 'Isle of Man', 'Italy', 'IT', 'Japan', 'JP', @@ -30,7 +31,7 @@ ALL_COUNTRIES = [ 'Northern Ireland', 'Norway', 'NO', 'Polish', 'PL', 'Portugal', 'PT', 'PortugalExt', 'PTE', 'Scotland', 'Slovenia', 'SI', 'Slovakia', 'SK', 'South Africa', 'ZA', 'Spain', 'ES', 'Sweden', 'SE', 'Switzerland', 'CH', - 'UnitedKingdom', 'UK', 'UnitedStates', 'US', 'Wales', + 'Ukraine', 'UA', 'UnitedKingdom', 'UK', 'UnitedStates', 'US', 'Wales', ] ALLOWED_DAYS = WEEKDAYS + ['holiday'] diff --git a/homeassistant/components/binary_sensor/xiaomi_aqara.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py index 730b662b90b..e082c886f03 100644 --- a/homeassistant/components/binary_sensor/xiaomi_aqara.py +++ b/homeassistant/components/binary_sensor/xiaomi_aqara.py @@ -4,6 +4,8 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.core import callback +from homeassistant.helpers.event import async_call_later _LOGGER = logging.getLogger(__name__) @@ -36,21 +38,24 @@ def setup_platform(hass, config, add_entities, discovery_info=None): elif model in ['natgas', 'sensor_natgas']: devices.append(XiaomiNatgasSensor(device, gateway)) elif model in ['switch', 'sensor_switch', - 'sensor_switch.aq2', 'sensor_switch.aq3']: + 'sensor_switch.aq2', 'sensor_switch.aq3', + 'remote.b1acn01']: if 'proto' not in device or int(device['proto'][0:1]) == 1: data_key = 'status' else: data_key = 'button_0' devices.append(XiaomiButton(device, 'Switch', data_key, hass, gateway)) - elif model in ['86sw1', 'sensor_86sw1', 'sensor_86sw1.aq1']: + elif model in ['86sw1', 'sensor_86sw1', 'sensor_86sw1.aq1', + 'remote.b186acn01']: if 'proto' not in device or int(device['proto'][0:1]) == 1: data_key = 'channel_0' else: data_key = 'button_0' devices.append(XiaomiButton(device, 'Wall Switch', data_key, hass, gateway)) - elif model in ['86sw2', 'sensor_86sw2', 'sensor_86sw2.aq1']: + elif model in ['86sw2', 'sensor_86sw2', 'sensor_86sw2.aq1', + 'remote.b286acn01']: if 'proto' not in device or int(device['proto'][0:1]) == 1: data_key_left = 'channel_0' data_key_right = 'channel_1' @@ -65,6 +70,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 'dual_channel', hass, gateway)) elif model in ['cube', 'sensor_cube', 'sensor_cube.aqgl01']: devices.append(XiaomiCube(device, hass, gateway)) + elif model in ['vibration', 'vibration.aq1']: + devices.append(XiaomiVibration(device, 'Vibration', + 'status', gateway)) + else: + _LOGGER.warning('Unmapped Device Model %s', model) + add_entities(devices) @@ -144,6 +155,7 @@ class XiaomiMotionSensor(XiaomiBinarySensor): """Initialize the XiaomiMotionSensor.""" self._hass = hass self._no_motion_since = 0 + self._unsub_set_no_motion = None if 'proto' not in device or int(device['proto'][0:1]) == 1: data_key = 'status' else: @@ -158,6 +170,13 @@ class XiaomiMotionSensor(XiaomiBinarySensor): attrs.update(super().device_state_attributes) return attrs + @callback + def _async_set_no_motion(self, now): + """Set state to False.""" + self._unsub_set_no_motion = None + self._state = False + self.async_schedule_update_ha_state() + def parse_data(self, data, raw_data): """Parse data sent by gateway.""" if raw_data['cmd'] == 'heartbeat': @@ -179,11 +198,20 @@ class XiaomiMotionSensor(XiaomiBinarySensor): return False if value == MOTION: - self._should_poll = True - if self.entity_id is not None: - self._hass.bus.fire('motion', { - 'entity_id': self.entity_id - }) + if self._data_key == 'motion_status': + if self._unsub_set_no_motion: + self._unsub_set_no_motion() + self._unsub_set_no_motion = async_call_later( + self._hass, + 180, + self._async_set_no_motion + ) + else: + self._should_poll = True + if self.entity_id is not None: + self._hass.bus.fire('motion', { + 'entity_id': self.entity_id + }) self._no_motion_since = 0 if self._state: @@ -311,6 +339,38 @@ class XiaomiSmokeSensor(XiaomiBinarySensor): return False +class XiaomiVibration(XiaomiBinarySensor): + """Representation of a Xiaomi Vibration Sensor.""" + + def __init__(self, device, name, data_key, xiaomi_hub): + """Initialize the XiaomiVibration.""" + self._last_action = None + super().__init__(device, name, xiaomi_hub, data_key, None) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {ATTR_LAST_ACTION: self._last_action} + attrs.update(super().device_state_attributes) + return attrs + + def parse_data(self, data, raw_data): + """Parse data sent by gateway.""" + value = data.get(self._data_key) + if value not in ('vibrate', 'tilt', 'free_fall'): + _LOGGER.warning("Unsupported movement_type detected: %s", + value) + return False + + self.hass.bus.fire('xiaomi_aqara.movement', { + 'entity_id': self.entity_id, + 'movement_type': value + }) + self._last_action = value + + return True + + class XiaomiButton(XiaomiBinarySensor): """Representation of a Xiaomi Button.""" diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py index aa07a673c97..fa24ed89980 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -253,5 +253,9 @@ class Remote(zha.Entity, BinarySensorDevice): """Retrieve latest state.""" from zigpy.zcl.clusters.general import OnOff result = await zha.safe_read( - self._endpoint.out_clusters[OnOff.cluster_id], ['on_off']) + self._endpoint.out_clusters[OnOff.cluster_id], + ['on_off'], + allow_cache=False, + only_cache=(not self._initialized) + ) self._state = result.get('on_off', self._state) diff --git a/homeassistant/components/binary_sensor/zwave.py b/homeassistant/components/binary_sensor/zwave.py index 3bb3a3c79c5..ca07986976d 100644 --- a/homeassistant/components/binary_sensor/zwave.py +++ b/homeassistant/components/binary_sensor/zwave.py @@ -7,10 +7,11 @@ https://home-assistant.io/components/binary_sensor.zwave/ import logging import datetime import homeassistant.util.dt as dt_util +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import track_point_in_time from homeassistant.components import zwave -from homeassistant.components.zwave import ( # noqa pylint: disable=unused-import - async_setup_platform, workaround) +from homeassistant.components.zwave import workaround from homeassistant.components.binary_sensor import ( DOMAIN, BinarySensorDevice) @@ -19,6 +20,23 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = [] +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old method of setting up Z-Wave binary sensors.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Z-Wave binary sensors from Config Entry.""" + @callback + def async_add_binary_sensor(binary_sensor): + """Add Z-Wave binary sensor.""" + async_add_entities([binary_sensor]) + + async_dispatcher_connect(hass, 'zwave_new_binary_sensor', + async_add_binary_sensor) + + def get_device(values, **kwargs): """Create Z-Wave entity device.""" device_mapping = workaround.get_device_mapping(values.primary) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index abdbc1a2e92..66cfe3990a3 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( CONF_BINARY_SENSORS, CONF_SENSORS, CONF_FILENAME, CONF_MONITORED_CONDITIONS, TEMP_FAHRENHEIT) -REQUIREMENTS = ['blinkpy==0.10.0'] +REQUIREMENTS = ['blinkpy==0.10.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index dce5961d70d..40f2b91045a 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -9,7 +9,7 @@ import logging import voluptuous as vol -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD) from homeassistant.helpers import discovery from homeassistant.helpers.event import track_utc_time_change import homeassistant.helpers.config_validation as cv @@ -85,6 +85,7 @@ def setup_account(account_config: dict, hass, name: str) \ password = account_config[CONF_PASSWORD] region = account_config[CONF_REGION] read_only = account_config[CONF_READ_ONLY] + _LOGGER.debug('Adding new account %s', name) cd_account = BMWConnectedDriveAccount(username, password, region, name, read_only) diff --git a/homeassistant/components/calendar/google.py b/homeassistant/components/calendar/google.py index 041b98dc24b..abb4fd28dd4 100644 --- a/homeassistant/components/calendar/google.py +++ b/homeassistant/components/calendar/google.py @@ -68,6 +68,7 @@ class GoogleCalendarData: self.event = None def _prepare_query(self): + # pylint: disable=import-error from httplib2 import ServerNotFoundError try: diff --git a/homeassistant/components/calendar/todoist.py b/homeassistant/components/calendar/todoist.py index b5eaed4e6c9..a0a3457667f 100644 --- a/homeassistant/components/calendar/todoist.py +++ b/homeassistant/components/calendar/todoist.py @@ -518,6 +518,8 @@ class TodoistProjectData: def update(self): """Get the latest data.""" if self._id is None: + self._api.reset_state() + self._api.sync() project_task_data = [ task for task in self._api.state[TASKS] if not self._project_id_whitelist or @@ -527,6 +529,7 @@ class TodoistProjectData: # If we have no data, we can just return right away. if not project_task_data: + _LOGGER.debug("No data for %s", self._name) self.event = None return True @@ -541,6 +544,8 @@ class TodoistProjectData: if not project_tasks: # We had no valid tasks + _LOGGER.debug("No valid tasks for %s", self._name) + self.event = None return True # Make sure the task collection is reset to prevent an diff --git a/homeassistant/components/camera/bloomsky.py b/homeassistant/components/camera/bloomsky.py index 01e20e3ccd3..1c9266ca3a7 100644 --- a/homeassistant/components/camera/bloomsky.py +++ b/homeassistant/components/camera/bloomsky.py @@ -53,6 +53,11 @@ class BloomSkyCamera(Camera): return self._last_image + @property + def unique_id(self): + """Return a unique ID.""" + return self._id + @property def name(self): """Return the name of this BloomSky device.""" diff --git a/homeassistant/components/camera/ffmpeg.py b/homeassistant/components/camera/ffmpeg.py index c458188695a..dfbcc4d70bc 100644 --- a/homeassistant/components/camera/ffmpeg.py +++ b/homeassistant/components/camera/ffmpeg.py @@ -32,7 +32,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up a FFmpeg camera.""" - if not hass.data[DATA_FFMPEG].async_run_test(config.get(CONF_INPUT)): + if not await hass.data[DATA_FFMPEG].async_run_test(config.get(CONF_INPUT)): return async_add_entities([FFmpegCamera(hass, config)]) diff --git a/homeassistant/components/camera/neato.py b/homeassistant/components/camera/neato.py index b080dbbae10..4df423344bb 100644 --- a/homeassistant/components/camera/neato.py +++ b/homeassistant/components/camera/neato.py @@ -63,3 +63,8 @@ class NeatoCleaningMap(Camera): def name(self): """Return the name of this camera.""" return self._robot_name + + @property + def unique_id(self): + """Return unique ID.""" + return self._robot_serial diff --git a/homeassistant/components/camera/ring.py b/homeassistant/components/camera/ring.py index ae886bd0669..eafb3066e48 100644 --- a/homeassistant/components/camera/ring.py +++ b/homeassistant/components/camera/ring.py @@ -97,6 +97,11 @@ class RingCam(Camera): """Return the name of this camera.""" return self._name + @property + def unique_id(self): + """Return a unique ID.""" + return self._camera.id + @property def device_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/camera/xiaomi.py b/homeassistant/components/camera/xiaomi.py index da36299a209..3d6b51cf229 100644 --- a/homeassistant/components/camera/xiaomi.py +++ b/homeassistant/components/camera/xiaomi.py @@ -35,7 +35,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_MODEL): vol.Any(MODEL_YI, MODEL_XIAOFANG), - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, diff --git a/homeassistant/components/camera/yi.py b/homeassistant/components/camera/yi.py index eb26c1cc887..f3800ee0648 100644 --- a/homeassistant/components/camera/yi.py +++ b/homeassistant/components/camera/yi.py @@ -32,7 +32,7 @@ CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, diff --git a/homeassistant/components/cast/.translations/pt.json b/homeassistant/components/cast/.translations/pt.json index a6d28538396..85d1b14484d 100644 --- a/homeassistant/components/cast/.translations/pt.json +++ b/homeassistant/components/cast/.translations/pt.json @@ -7,9 +7,9 @@ "step": { "confirm": { "description": "Deseja configurar o Google Cast?", - "title": "" + "title": "Google Cast" } }, - "title": "" + "title": "Google Cast" } } \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/ro.json b/homeassistant/components/cast/.translations/ro.json new file mode 100644 index 00000000000..8a1d19c0ecf --- /dev/null +++ b/homeassistant/components/cast/.translations/ro.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nu s-au g\u0103sit dispozitive Google Cast \u00een re\u021bea.", + "single_instance_allowed": "Este necesar\u0103 o singur\u0103 configura\u021bie a serviciului Google Cast." + }, + "step": { + "confirm": { + "description": "Dori\u021bi s\u0103 configura\u021bi Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 98483c454bc..a165521f0bd 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -48,11 +48,6 @@ STATE_MANUAL = 'manual' STATE_DRY = 'dry' STATE_FAN_ONLY = 'fan_only' STATE_ECO = 'eco' -STATE_ELECTRIC = 'electric' -STATE_PERFORMANCE = 'performance' -STATE_HIGH_DEMAND = 'high_demand' -STATE_HEAT_PUMP = 'heat_pump' -STATE_GAS = 'gas' SUPPORT_TARGET_TEMPERATURE = 1 SUPPORT_TARGET_TEMPERATURE_HIGH = 2 diff --git a/homeassistant/components/climate/daikin.py b/homeassistant/components/climate/daikin.py index 6743bf034dc..66e380ad68d 100644 --- a/homeassistant/components/climate/daikin.py +++ b/homeassistant/components/climate/daikin.py @@ -40,6 +40,15 @@ HA_STATE_TO_DAIKIN = { STATE_OFF: 'off', } +DAIKIN_TO_HA_STATE = { + 'fan': STATE_FAN_ONLY, + 'dry': STATE_DRY, + 'cool': STATE_COOL, + 'hot': STATE_HEAT, + 'auto': STATE_AUTO, + 'off': STATE_OFF, +} + HA_ATTR_TO_DAIKIN = { ATTR_OPERATION_MODE: 'mode', ATTR_FAN_MODE: 'f_rate', @@ -75,9 +84,7 @@ class DaikinClimate(ClimateDevice): self._api = api self._force_refresh = False self._list = { - ATTR_OPERATION_MODE: list( - map(str.title, set(HA_STATE_TO_DAIKIN.values())) - ), + ATTR_OPERATION_MODE: list(HA_STATE_TO_DAIKIN), ATTR_FAN_MODE: list( map( str.title, @@ -136,11 +143,11 @@ class DaikinClimate(ClimateDevice): elif key == ATTR_OPERATION_MODE: # Daikin can return also internal states auto-1 or auto-7 # and we need to translate them as AUTO - value = re.sub( - '[^a-z]', - '', - self._api.device.represent(daikin_attr)[1] - ).title() + daikin_mode = re.sub( + '[^a-z]', '', + self._api.device.represent(daikin_attr)[1]) + ha_mode = DAIKIN_TO_HA_STATE.get(daikin_mode) + value = ha_mode if value is None: _LOGGER.error("Invalid value requested for key %s", key) @@ -167,8 +174,8 @@ class DaikinClimate(ClimateDevice): daikin_attr = HA_ATTR_TO_DAIKIN.get(attr) if daikin_attr is not None: - if value.title() in self._list[attr]: - values[daikin_attr] = value.lower() + if value in self._list[attr]: + values[daikin_attr] = HA_STATE_TO_DAIKIN[value] else: _LOGGER.error("Invalid value %s for %s", attr, value) diff --git a/homeassistant/components/climate/dyson.py b/homeassistant/components/climate/dyson.py new file mode 100644 index 00000000000..0b09ec7f0b4 --- /dev/null +++ b/homeassistant/components/climate/dyson.py @@ -0,0 +1,176 @@ +""" +Support for Dyson Pure Hot+Cool link fan. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.dyson/ +""" +import logging + +from homeassistant.components.dyson import DYSON_DEVICES +from homeassistant.components.climate import ( + ClimateDevice, STATE_HEAT, STATE_COOL, STATE_IDLE, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE) +from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE + +_LOGGER = logging.getLogger(__name__) + +STATE_DIFFUSE = "Diffuse Mode" +STATE_FOCUS = "Focus Mode" +FAN_LIST = [STATE_FOCUS, STATE_DIFFUSE] +OPERATION_LIST = [STATE_HEAT, STATE_COOL] + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE + | SUPPORT_OPERATION_MODE) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Dyson fan components.""" + if discovery_info is None: + return + + from libpurecoollink.dyson_pure_hotcool_link import DysonPureHotCoolLink + # Get Dyson Devices from parent component. + add_devices( + [DysonPureHotCoolLinkDevice(device) + for device in hass.data[DYSON_DEVICES] + if isinstance(device, DysonPureHotCoolLink)] + ) + + +class DysonPureHotCoolLinkDevice(ClimateDevice): + """Representation of a Dyson climate fan.""" + + def __init__(self, device): + """Initialize the fan.""" + self._device = device + self._current_temp = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.hass.async_add_job(self._device.add_message_listener, + self.on_message) + + def on_message(self, message): + """Call when new messages received from the climate.""" + from libpurecoollink.dyson_pure_state import DysonPureHotCoolState + + if isinstance(message, DysonPureHotCoolState): + _LOGGER.debug("Message received for climate device %s : %s", + self.name, message) + self.schedule_update_ha_state() + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def name(self): + """Return the display name of this climate.""" + return self._device.name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + if self._device.environmental_state: + temperature_kelvin = self._device.environmental_state.temperature + if temperature_kelvin != 0: + self._current_temp = float("{0:.1f}".format( + temperature_kelvin - 273)) + return self._current_temp + + @property + def target_temperature(self): + """Return the target temperature.""" + heat_target = int(self._device.state.heat_target) / 10 + return int(heat_target - 273) + + @property + def current_humidity(self): + """Return the current humidity.""" + if self._device.environmental_state: + if self._device.environmental_state.humidity == 0: + return None + return self._device.environmental_state.humidity + return None + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + from libpurecoollink.const import HeatMode, HeatState + if self._device.state.heat_mode == HeatMode.HEAT_ON.value: + if self._device.state.heat_state == HeatState.HEAT_STATE_ON.value: + return STATE_HEAT + return STATE_IDLE + return STATE_COOL + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return OPERATION_LIST + + @property + def current_fan_mode(self): + """Return the fan setting.""" + from libpurecoollink.const import FocusMode + if self._device.state.focus_mode == FocusMode.FOCUS_ON.value: + return STATE_FOCUS + return STATE_DIFFUSE + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return FAN_LIST + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + target_temp = kwargs.get(ATTR_TEMPERATURE) + if target_temp is None: + return + target_temp = int(target_temp) + _LOGGER.debug("Set %s temperature %s", self.name, target_temp) + # Limit the target temperature into acceptable range. + target_temp = min(self.max_temp, target_temp) + target_temp = max(self.min_temp, target_temp) + from libpurecoollink.const import HeatTarget, HeatMode + self._device.set_configuration( + heat_target=HeatTarget.celsius(target_temp), + heat_mode=HeatMode.HEAT_ON) + + def set_fan_mode(self, fan_mode): + """Set new fan mode.""" + _LOGGER.debug("Set %s focus mode %s", self.name, fan_mode) + from libpurecoollink.const import FocusMode + if fan_mode == STATE_FOCUS: + self._device.set_configuration(focus_mode=FocusMode.FOCUS_ON) + elif fan_mode == STATE_DIFFUSE: + self._device.set_configuration(focus_mode=FocusMode.FOCUS_OFF) + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + _LOGGER.debug("Set %s heat mode %s", self.name, operation_mode) + from libpurecoollink.const import HeatMode + if operation_mode == STATE_HEAT: + self._device.set_configuration(heat_mode=HeatMode.HEAT_ON) + elif operation_mode == STATE_COOL: + self._device.set_configuration(heat_mode=HeatMode.HEAT_OFF) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return 1 + + @property + def max_temp(self): + """Return the maximum temperature.""" + return 37 diff --git a/homeassistant/components/climate/elkm1.py b/homeassistant/components/climate/elkm1.py new file mode 100644 index 00000000000..6bd33b382dc --- /dev/null +++ b/homeassistant/components/climate/elkm1.py @@ -0,0 +1,193 @@ +""" +Support for control of Elk-M1 connected thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.elkm1/ +""" +from homeassistant.components.climate import ( + ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, PRECISION_WHOLE, STATE_AUTO, + STATE_COOL, STATE_FAN_ONLY, STATE_HEAT, STATE_IDLE, SUPPORT_AUX_HEAT, + SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE_HIGH, + SUPPORT_TARGET_TEMPERATURE_LOW, ClimateDevice) +from homeassistant.components.elkm1 import ( + DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities) +from homeassistant.const import STATE_ON + +DEPENDENCIES = [ELK_DOMAIN] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Create the Elk-M1 thermostat platform.""" + if discovery_info is None: + return + + elk = hass.data[ELK_DOMAIN]['elk'] + async_add_entities(create_elk_entities( + hass, elk.thermostats, 'thermostat', ElkThermostat, []), True) + + +class ElkThermostat(ElkEntity, ClimateDevice): + """Representation of an Elk-M1 Thermostat.""" + + def __init__(self, element, elk, elk_data): + """Initialize climate entity.""" + super().__init__(element, elk, elk_data) + self._state = None + + @property + def supported_features(self): + """Return the list of supported features.""" + return (SUPPORT_OPERATION_MODE | SUPPORT_FAN_MODE | SUPPORT_AUX_HEAT + | SUPPORT_TARGET_TEMPERATURE_HIGH + | SUPPORT_TARGET_TEMPERATURE_LOW) + + @property + def temperature_unit(self): + """Return the temperature unit.""" + return self._temperature_unit + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._element.current_temp + + @property + def target_temperature(self): + """Return the temperature we are trying to reach.""" + from elkm1_lib.const import ThermostatMode + if (self._element.mode == ThermostatMode.HEAT.value) or ( + self._element.mode == ThermostatMode.EMERGENCY_HEAT.value): + return self._element.heat_setpoint + if self._element.mode == ThermostatMode.COOL.value: + return self._element.cool_setpoint + return None + + @property + def target_temperature_high(self): + """Return the high target temperature.""" + return self._element.cool_setpoint + + @property + def target_temperature_low(self): + """Return the low target temperature.""" + return self._element.heat_setpoint + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return 1 + + @property + def current_humidity(self): + """Return the current humidity.""" + return self._element.humidity + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return self._state + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return [STATE_IDLE, STATE_HEAT, STATE_COOL, STATE_AUTO, STATE_FAN_ONLY] + + @property + def precision(self): + """Return the precision of the system.""" + return PRECISION_WHOLE + + @property + def is_aux_heat_on(self): + """Return if aux heater is on.""" + from elkm1_lib.const import ThermostatMode + return self._element.mode == ThermostatMode.EMERGENCY_HEAT.value + + @property + def min_temp(self): + """Return the minimum temperature supported.""" + return 1 + + @property + def max_temp(self): + """Return the maximum temperature supported.""" + return 99 + + @property + def current_fan_mode(self): + """Return the fan setting.""" + from elkm1_lib.const import ThermostatFan + if self._element.fan == ThermostatFan.AUTO.value: + return STATE_AUTO + if self._element.fan == ThermostatFan.ON.value: + return STATE_ON + return None + + def _elk_set(self, mode, fan): + from elkm1_lib.const import ThermostatSetting + if mode is not None: + self._element.set(ThermostatSetting.MODE.value, mode) + if fan is not None: + self._element.set(ThermostatSetting.FAN.value, fan) + + async def async_set_operation_mode(self, operation_mode): + """Set thermostat operation mode.""" + from elkm1_lib.const import ThermostatFan, ThermostatMode + settings = { + STATE_IDLE: (ThermostatMode.OFF.value, ThermostatFan.AUTO.value), + STATE_HEAT: (ThermostatMode.HEAT.value, None), + STATE_COOL: (ThermostatMode.COOL.value, None), + STATE_AUTO: (ThermostatMode.AUTO.value, None), + STATE_FAN_ONLY: (ThermostatMode.OFF.value, ThermostatFan.ON.value) + } + self._elk_set(settings[operation_mode][0], settings[operation_mode][1]) + + async def async_turn_aux_heat_on(self): + """Turn auxiliary heater on.""" + from elkm1_lib.const import ThermostatMode + self._elk_set(ThermostatMode.EMERGENCY_HEAT.value, None) + + async def async_turn_aux_heat_off(self): + """Turn auxiliary heater off.""" + from elkm1_lib.const import ThermostatMode + self._elk_set(ThermostatMode.HEAT.value, None) + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return [STATE_AUTO, STATE_ON] + + async def async_set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + from elkm1_lib.const import ThermostatFan + if fan_mode == STATE_AUTO: + self._elk_set(None, ThermostatFan.AUTO.value) + elif fan_mode == STATE_ON: + self._elk_set(None, ThermostatFan.ON.value) + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + from elkm1_lib.const import ThermostatSetting + low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) + high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) + if low_temp is not None: + self._element.set( + ThermostatSetting.HEAT_SETPOINT.value, round(low_temp)) + if high_temp is not None: + self._element.set( + ThermostatSetting.COOL_SETPOINT.value, round(high_temp)) + + def _element_changed(self, element, changeset): + from elkm1_lib.const import ThermostatFan, ThermostatMode + mode_to_state = { + ThermostatMode.OFF.value: STATE_IDLE, + ThermostatMode.COOL.value: STATE_COOL, + ThermostatMode.HEAT.value: STATE_HEAT, + ThermostatMode.EMERGENCY_HEAT.value: STATE_HEAT, + ThermostatMode.AUTO.value: STATE_AUTO, + } + self._state = mode_to_state.get(self._element.mode) + if self._state == STATE_IDLE and \ + self._element.fan == ThermostatFan.ON.value: + self._state = STATE_FAN_ONLY diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index 904d8222e88..bb0a9d4b810 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -10,12 +10,13 @@ import voluptuous as vol from homeassistant.components.climate import ( STATE_ON, STATE_OFF, STATE_AUTO, PLATFORM_SCHEMA, ClimateDevice, - SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE) + SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE, + SUPPORT_ON_OFF) from homeassistant.const import ( CONF_MAC, CONF_DEVICES, TEMP_CELSIUS, ATTR_TEMPERATURE, PRECISION_HALVES) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-eq3bt==0.1.9', 'construct==2.9.41'] +REQUIREMENTS = ['python-eq3bt==0.1.9', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) @@ -39,7 +40,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | - SUPPORT_AWAY_MODE) + SUPPORT_AWAY_MODE | SUPPORT_ON_OFF) def setup_platform(hass, config, add_entities, discovery_info=None): @@ -53,14 +54,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(devices) -# pylint: disable=import-error class EQ3BTSmartThermostat(ClimateDevice): """Representation of an eQ-3 Bluetooth Smart thermostat.""" def __init__(self, _mac, _name): """Initialize the thermostat.""" # We want to avoid name clash with this module. - import eq3bt as eq3 + import eq3bt as eq3 # pylint: disable=import-error self.modes = { eq3.Mode.Open: STATE_ON, @@ -151,6 +151,14 @@ class EQ3BTSmartThermostat(ClimateDevice): """Return if we are away.""" return self.current_operation == STATE_AWAY + def turn_on(self): + """Turn device on.""" + self.set_operation_mode(STATE_AUTO) + + def turn_off(self): + """Turn device off.""" + self.set_operation_mode(STATE_OFF) + @property def min_temp(self): """Return the minimum temperature.""" @@ -176,7 +184,7 @@ class EQ3BTSmartThermostat(ClimateDevice): def update(self): """Update the data from the thermostat.""" - from bluepy.btle import BTLEException + from bluepy.btle import BTLEException # pylint: disable=import-error try: self._thermostat.update() except BTLEException as ex: diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index 4eada356653..b52bd4b418d 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -75,7 +75,7 @@ def async_add_entities_discovery(hass, discovery_info, async_add_entities): entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] - entities.append(KNXClimate(hass, device)) + entities.append(KNXClimate(device)) async_add_entities(entities) @@ -110,17 +110,15 @@ def async_add_entities_config(hass, config, async_add_entities): group_address_operation_mode_comfort=config.get( CONF_OPERATION_MODE_COMFORT_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(climate) - async_add_entities([KNXClimate(hass, climate)]) + async_add_entities([KNXClimate(climate)]) class KNXClimate(ClimateDevice): """Representation of a KNX climate device.""" - def __init__(self, hass, device): + def __init__(self, device): """Initialize of a KNX climate device.""" self.device = device - self.hass = hass - self.async_register_callbacks() @property def supported_features(self): @@ -137,6 +135,10 @@ class KNXClimate(ClimateDevice): await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) + async def async_added_to_hass(self): + """Store register state change callback.""" + self.async_register_callbacks() + @property def name(self): """Return the name of the KNX device.""" diff --git a/homeassistant/components/climate/mill.py b/homeassistant/components/climate/mill.py new file mode 100644 index 00000000000..763e239689b --- /dev/null +++ b/homeassistant/components/climate/mill.py @@ -0,0 +1,153 @@ +""" +Support for mill wifi-enabled home heaters. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.mill/ +""" + +import logging + +import voluptuous as vol +from homeassistant.components.climate import ( + ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_FAN_MODE, SUPPORT_ON_OFF) +from homeassistant.const import ( + ATTR_TEMPERATURE, CONF_PASSWORD, CONF_USERNAME, + STATE_ON, STATE_OFF, TEMP_CELSIUS) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +REQUIREMENTS = ['millheater==0.1.2'] + +_LOGGER = logging.getLogger(__name__) + +MAX_TEMP = 35 +MIN_TEMP = 5 +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | + SUPPORT_FAN_MODE | SUPPORT_ON_OFF) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Mill heater.""" + from mill import Mill + mill_data_connection = Mill(config[CONF_USERNAME], + config[CONF_PASSWORD], + websession=async_get_clientsession(hass)) + if not await mill_data_connection.connect(): + _LOGGER.error("Failed to connect to Mill") + return + + await mill_data_connection.update_heaters() + + dev = [] + for heater in mill_data_connection.heaters.values(): + dev.append(MillHeater(heater, mill_data_connection)) + async_add_entities(dev) + + +class MillHeater(ClimateDevice): + """Representation of a Mill Thermostat device.""" + + def __init__(self, heater, mill_data_connection): + """Initialize the thermostat.""" + self._heater = heater + self._conn = mill_data_connection + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def available(self): + """Return True if entity is available.""" + return self._heater.device_status == 0 # weird api choice + + @property + def unique_id(self): + """Return a unique ID.""" + return self._heater.device_id + + @property + def name(self): + """Return the name of the entity.""" + return self._heater.name + + @property + def temperature_unit(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._heater.set_temp + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return 1 + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._heater.current_temp + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return STATE_ON if self._heater.fan_status == 1 else STATE_OFF + + @property + def fan_list(self): + """List of available fan modes.""" + return [STATE_ON, STATE_OFF] + + @property + def is_on(self): + """Return true if heater is on.""" + return self._heater.power_status == 1 + + @property + def min_temp(self): + """Return the minimum temperature.""" + return MIN_TEMP + + @property + def max_temp(self): + """Return the maximum temperature.""" + return MAX_TEMP + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + await self._conn.set_heater_temp(self._heater.device_id, + int(temperature)) + + async def async_set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + fan_status = 1 if fan_mode == STATE_ON else 0 + await self._conn.heater_control(self._heater.device_id, + fan_status=fan_status) + + async def async_turn_on(self): + """Turn Mill unit on.""" + await self._conn.heater_control(self._heater.device_id, + power_status=1) + + async def async_turn_off(self): + """Turn Mill unit off.""" + await self._conn.heater_control(self._heater.device_id, + power_status=0) + + async def async_update(self): + """Retrieve latest state.""" + self._heater = await self._conn.update_device(self._heater.device_id) diff --git a/homeassistant/components/climate/mqtt.py b/homeassistant/components/climate/mqtt.py index 79c49db7955..b107710fea5 100644 --- a/homeassistant/components/climate/mqtt.py +++ b/homeassistant/components/climate/mqtt.py @@ -75,6 +75,7 @@ CONF_SEND_IF_OFF = 'send_if_off' CONF_MIN_TEMP = 'min_temp' CONF_MAX_TEMP = 'max_temp' +CONF_TEMP_STEP = 'temp_step' SCHEMA_BASE = CLIMATE_PLATFORM_SCHEMA.extend(MQTT_BASE_PLATFORM_SCHEMA.schema) PLATFORM_SCHEMA = SCHEMA_BASE.extend({ @@ -124,7 +125,8 @@ PLATFORM_SCHEMA = SCHEMA_BASE.extend({ vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string, vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): vol.Coerce(float), - vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float) + vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float), + vol.Optional(CONF_TEMP_STEP, default=1.0): vol.Coerce(float) }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @@ -213,6 +215,7 @@ async def _async_setup_entity(hass, config, async_add_entities, config.get(CONF_PAYLOAD_NOT_AVAILABLE), config.get(CONF_MIN_TEMP), config.get(CONF_MAX_TEMP), + config.get(CONF_TEMP_STEP), discovery_hash, )]) @@ -226,7 +229,7 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): current_swing_mode, current_operation, aux, send_if_off, payload_on, payload_off, availability_topic, payload_available, payload_not_available, - min_temp, max_temp, discovery_hash): + min_temp, max_temp, temp_step, discovery_hash): """Initialize the climate device.""" MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) @@ -237,19 +240,26 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): self._value_templates = value_templates self._qos = qos self._retain = retain - self._target_temperature = target_temperature + # set to None in non-optimistic mode + self._target_temperature = self._current_fan_mode = \ + self._current_operation = self._current_swing_mode = None + if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is None: + self._target_temperature = target_temperature self._unit_of_measurement = hass.config.units.temperature_unit self._away = away self._hold = hold self._current_temperature = None - self._current_fan_mode = current_fan_mode - self._current_operation = current_operation + if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None: + self._current_fan_mode = current_fan_mode + if self._topic[CONF_MODE_STATE_TOPIC] is None: + self._current_operation = current_operation self._aux = aux - self._current_swing_mode = current_swing_mode + if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None: + self._current_swing_mode = current_swing_mode self._fan_list = fan_mode_list self._operation_list = mode_list self._swing_list = swing_mode_list - self._target_temperature_step = 1 + self._target_temperature_step = temp_step self._send_if_off = send_if_off self._payload_on = payload_on self._payload_off = payload_off diff --git a/homeassistant/components/climate/opentherm_gw.py b/homeassistant/components/climate/opentherm_gw.py index 00049d26b7f..6dc52e6acc7 100644 --- a/homeassistant/components/climate/opentherm_gw.py +++ b/homeassistant/components/climate/opentherm_gw.py @@ -1,34 +1,23 @@ """ -Support for OpenTherm Gateway devices. +Support for OpenTherm Gateway climate devices. -For more details about this component, please refer to the documentation at +For more details about this platform, please refer to the documentation at http://home-assistant.io/components/climate.opentherm_gw/ """ import logging -import voluptuous as vol - -from homeassistant.components.climate import (ClimateDevice, PLATFORM_SCHEMA, - STATE_IDLE, STATE_HEAT, - STATE_COOL, +from homeassistant.components.climate import (ClimateDevice, STATE_IDLE, + STATE_HEAT, STATE_COOL, SUPPORT_TARGET_TEMPERATURE) -from homeassistant.const import (ATTR_TEMPERATURE, CONF_DEVICE, CONF_NAME, - PRECISION_HALVES, PRECISION_TENTHS, - TEMP_CELSIUS, PRECISION_WHOLE) -import homeassistant.helpers.config_validation as cv +from homeassistant.components.opentherm_gw import ( + CONF_FLOOR_TEMP, CONF_PRECISION, DATA_DEVICE, DATA_GW_VARS, + DATA_OPENTHERM_GW, SIGNAL_OPENTHERM_GW_UPDATE) +from homeassistant.const import (ATTR_TEMPERATURE, CONF_NAME, PRECISION_HALVES, + PRECISION_TENTHS, PRECISION_WHOLE, + TEMP_CELSIUS) +from homeassistant.helpers.dispatcher import async_dispatcher_connect -REQUIREMENTS = ['pyotgw==0.1b0'] - -CONF_FLOOR_TEMP = "floor_temperature" -CONF_PRECISION = 'precision' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICE): cv.string, - vol.Optional(CONF_NAME, default="OpenTherm Gateway"): cv.string, - vol.Optional(CONF_PRECISION): vol.In([PRECISION_TENTHS, PRECISION_HALVES, - PRECISION_WHOLE]), - vol.Optional(CONF_FLOOR_TEMP, default=False): cv.boolean, -}) +DEPENDENCIES = ['opentherm_gw'] SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE) _LOGGER = logging.getLogger(__name__) @@ -37,19 +26,17 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the opentherm_gw device.""" - gateway = OpenThermGateway(config) + gateway = OpenThermGateway(hass, discovery_info) async_add_entities([gateway]) class OpenThermGateway(ClimateDevice): """Representation of a climate device.""" - def __init__(self, config): - """Initialize the sensor.""" - import pyotgw - self.pyotgw = pyotgw - self.gateway = self.pyotgw.pyotgw() - self._device = config[CONF_DEVICE] + def __init__(self, hass, config): + """Initialize the device.""" + self._gateway = hass.data[DATA_OPENTHERM_GW][DATA_DEVICE] + self._gw_vars = hass.data[DATA_OPENTHERM_GW][DATA_GW_VARS] self.friendly_name = config.get(CONF_NAME) self.floor_temp = config.get(CONF_FLOOR_TEMP) self.temp_precision = config.get(CONF_PRECISION) @@ -63,40 +50,38 @@ class OpenThermGateway(ClimateDevice): async def async_added_to_hass(self): """Connect to the OpenTherm Gateway device.""" - await self.gateway.connect(self.hass.loop, self._device) - self.gateway.subscribe(self.receive_report) - _LOGGER.debug("Connected to %s on %s", self.friendly_name, - self._device) + _LOGGER.debug("Added device %s", self.friendly_name) + async_dispatcher_connect(self.hass, SIGNAL_OPENTHERM_GW_UPDATE, + self.receive_report) async def receive_report(self, status): """Receive and handle a new report from the Gateway.""" - _LOGGER.debug("Received report: %s", status) - ch_active = status.get(self.pyotgw.DATA_SLAVE_CH_ACTIVE) - flame_on = status.get(self.pyotgw.DATA_SLAVE_FLAME_ON) - cooling_active = status.get(self.pyotgw.DATA_SLAVE_COOLING_ACTIVE) + ch_active = status.get(self._gw_vars.DATA_SLAVE_CH_ACTIVE) + flame_on = status.get(self._gw_vars.DATA_SLAVE_FLAME_ON) + cooling_active = status.get(self._gw_vars.DATA_SLAVE_COOLING_ACTIVE) if ch_active and flame_on: self._current_operation = STATE_HEAT elif cooling_active: self._current_operation = STATE_COOL else: self._current_operation = STATE_IDLE - self._current_temperature = status.get(self.pyotgw.DATA_ROOM_TEMP) + self._current_temperature = status.get(self._gw_vars.DATA_ROOM_TEMP) - temp = status.get(self.pyotgw.DATA_ROOM_SETPOINT_OVRD) + temp = status.get(self._gw_vars.DATA_ROOM_SETPOINT_OVRD) if temp is None: - temp = status.get(self.pyotgw.DATA_ROOM_SETPOINT) + temp = status.get(self._gw_vars.DATA_ROOM_SETPOINT) self._target_temperature = temp # GPIO mode 5: 0 == Away # GPIO mode 6: 1 == Away - gpio_a_state = status.get(self.pyotgw.OTGW_GPIO_A) + gpio_a_state = status.get(self._gw_vars.OTGW_GPIO_A) if gpio_a_state == 5: self._away_mode_a = 0 elif gpio_a_state == 6: self._away_mode_a = 1 else: self._away_mode_a = None - gpio_b_state = status.get(self.pyotgw.OTGW_GPIO_B) + gpio_b_state = status.get(self._gw_vars.OTGW_GPIO_B) if gpio_b_state == 5: self._away_mode_b = 0 elif gpio_b_state == 6: @@ -104,11 +89,11 @@ class OpenThermGateway(ClimateDevice): else: self._away_mode_b = None if self._away_mode_a is not None: - self._away_state_a = (status.get(self.pyotgw.OTGW_GPIO_A_STATE) == - self._away_mode_a) + self._away_state_a = (status.get( + self._gw_vars.OTGW_GPIO_A_STATE) == self._away_mode_a) if self._away_mode_b is not None: - self._away_state_b = (status.get(self.pyotgw.OTGW_GPIO_B_STATE) == - self._away_mode_b) + self._away_state_b = (status.get( + self._gw_vars.OTGW_GPIO_B_STATE) == self._away_mode_b) self.async_schedule_update_ha_state() @property @@ -170,7 +155,7 @@ class OpenThermGateway(ClimateDevice): """Set new target temperature.""" if ATTR_TEMPERATURE in kwargs: temp = float(kwargs[ATTR_TEMPERATURE]) - self._target_temperature = await self.gateway.set_target_temp( + self._target_temperature = await self._gateway.set_target_temp( temp) self.async_schedule_update_ha_state() diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index fbb21962c6e..e2a42770cb2 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -123,26 +123,6 @@ nuheat_resume_program: description: Name(s) of entities to change. example: 'climate.kitchen' -econet_add_vacation: - description: Add a vacation to your water heater. - fields: - entity_id: - description: Name(s) of entities to change. - example: 'climate.water_heater' - start_date: - description: The timestamp of when the vacation should start. (Optional, defaults to now) - example: 1513186320 - end_date: - description: The timestamp of when the vacation should end. - example: 1513445520 - -econet_delete_vacation: - description: Delete your existing vacation from your water heater. - fields: - entity_id: - description: Name(s) of entities to change. - example: 'climate.water_heater' - sensibo_assume_state: description: Set Sensibo device to external state. fields: diff --git a/homeassistant/components/climate/toon.py b/homeassistant/components/climate/toon.py index e759e922ee1..5972ff52a8b 100644 --- a/homeassistant/components/climate/toon.py +++ b/homeassistant/components/climate/toon.py @@ -8,7 +8,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.toon/ """ from homeassistant.components.climate import ( - ATTR_TEMPERATURE, STATE_COOL, STATE_ECO, STATE_HEAT, STATE_PERFORMANCE, + ATTR_TEMPERATURE, STATE_COOL, STATE_ECO, STATE_HEAT, STATE_AUTO, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, ClimateDevice) import homeassistant.components.toon as toon_main from homeassistant.const import TEMP_CELSIUS @@ -34,7 +34,7 @@ class ThermostatDevice(ClimateDevice): self._temperature = None self._setpoint = None self._operation_list = [ - STATE_PERFORMANCE, + STATE_AUTO, STATE_HEAT, STATE_ECO, STATE_COOL, @@ -84,7 +84,7 @@ class ThermostatDevice(ClimateDevice): def set_operation_mode(self, operation_mode): """Set new operation mode.""" toonlib_values = { - STATE_PERFORMANCE: 'Comfort', + STATE_AUTO: 'Comfort', STATE_HEAT: 'Home', STATE_ECO: 'Away', STATE_COOL: 'Sleep', diff --git a/homeassistant/components/climate/tuya.py b/homeassistant/components/climate/tuya.py index 2da46fee15d..4548867a45e 100644 --- a/homeassistant/components/climate/tuya.py +++ b/homeassistant/components/climate/tuya.py @@ -7,8 +7,7 @@ https://home-assistant.io/components/climate.tuya/ from homeassistant.components.climate import ( ATTR_TEMPERATURE, ENTITY_ID_FORMAT, STATE_AUTO, STATE_COOL, STATE_ECO, - STATE_ELECTRIC, STATE_FAN_ONLY, STATE_GAS, STATE_HEAT, STATE_HEAT_PUMP, - STATE_HIGH_DEMAND, STATE_PERFORMANCE, SUPPORT_FAN_MODE, SUPPORT_ON_OFF, + STATE_FAN_ONLY, STATE_HEAT, SUPPORT_FAN_MODE, SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, ClimateDevice) from homeassistant.components.fan import SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH from homeassistant.components.tuya import DATA_TUYA, TuyaDevice @@ -23,13 +22,8 @@ HA_STATE_TO_TUYA = { STATE_AUTO: 'auto', STATE_COOL: 'cold', STATE_ECO: 'eco', - STATE_ELECTRIC: 'electric', STATE_FAN_ONLY: 'wind', - STATE_GAS: 'gas', STATE_HEAT: 'hot', - STATE_HEAT_PUMP: 'heat_pump', - STATE_HIGH_DEMAND: 'high_demand', - STATE_PERFORMANCE: 'performance', } TUYA_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_TUYA.items()} diff --git a/homeassistant/components/climate/wink.py b/homeassistant/components/climate/wink.py index cb6204d3ba3..7e5230ba3c7 100644 --- a/homeassistant/components/climate/wink.py +++ b/homeassistant/components/climate/wink.py @@ -1,5 +1,5 @@ """ -Support for Wink thermostats, Air Conditioners, and Water Heaters. +Support for Wink thermostats and Air Conditioners. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.wink/ @@ -8,9 +8,9 @@ import logging from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - ATTR_TEMPERATURE, STATE_AUTO, STATE_COOL, STATE_ECO, STATE_ELECTRIC, - STATE_FAN_ONLY, STATE_GAS, STATE_HEAT, STATE_HEAT_PUMP, STATE_HIGH_DEMAND, - STATE_PERFORMANCE, SUPPORT_AUX_HEAT, SUPPORT_AWAY_MODE, SUPPORT_FAN_MODE, + ATTR_TEMPERATURE, STATE_AUTO, STATE_COOL, STATE_ECO, + STATE_FAN_ONLY, STATE_HEAT, SUPPORT_AUX_HEAT, + SUPPORT_AWAY_MODE, SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW, ClimateDevice) @@ -24,11 +24,9 @@ _LOGGER = logging.getLogger(__name__) ATTR_ECO_TARGET = 'eco_target' ATTR_EXTERNAL_TEMPERATURE = 'external_temperature' ATTR_OCCUPIED = 'occupied' -ATTR_RHEEM_TYPE = 'rheem_type' ATTR_SCHEDULE_ENABLED = 'schedule_enabled' ATTR_SMART_TEMPERATURE = 'smart_temperature' ATTR_TOTAL_CONSUMPTION = 'total_consumption' -ATTR_VACATION_MODE = 'vacation_mode' ATTR_HEAT_ON = 'heat_on' ATTR_COOL_ON = 'cool_on' @@ -42,14 +40,9 @@ HA_STATE_TO_WINK = { STATE_AUTO: 'auto', STATE_COOL: 'cool_only', STATE_ECO: 'eco', - STATE_ELECTRIC: 'electric_only', STATE_FAN_ONLY: 'fan_only', - STATE_GAS: 'gas', STATE_HEAT: 'heat_only', - STATE_HEAT_PUMP: 'heat_pump', - STATE_HIGH_DEMAND: 'high_demand', STATE_OFF: 'off', - STATE_PERFORMANCE: 'performance', } WINK_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_WINK.items()} @@ -62,9 +55,6 @@ SUPPORT_FLAGS_THERMOSTAT = ( SUPPORT_FLAGS_AC = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | SUPPORT_FAN_MODE) -SUPPORT_FLAGS_HEATER = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | - SUPPORT_AWAY_MODE) - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Wink climate devices.""" @@ -77,10 +67,6 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _id = climate.object_id() + climate.name() if _id not in hass.data[DOMAIN]['unique_ids']: add_entities([WinkAC(climate, hass)]) - for water_heater in pywink.get_water_heaters(): - _id = water_heater.object_id() + water_heater.name() - if _id not in hass.data[DOMAIN]['unique_ids']: - add_entities([WinkWaterHeater(water_heater, hass)]) class WinkThermostat(WinkDevice, ClimateDevice): @@ -504,93 +490,3 @@ class WinkAC(WinkDevice, ClimateDevice): elif fan_mode == SPEED_HIGH: speed = 1.0 self.wink.set_ac_fan_speed(speed) - - -class WinkWaterHeater(WinkDevice, ClimateDevice): - """Representation of a Wink water heater.""" - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS_HEATER - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - # The Wink API always returns temp in Celsius - return TEMP_CELSIUS - - @property - def device_state_attributes(self): - """Return the optional device state attributes.""" - data = {} - data[ATTR_VACATION_MODE] = self.wink.vacation_mode_enabled() - data[ATTR_RHEEM_TYPE] = self.wink.rheem_type() - - return data - - @property - def current_operation(self): - """ - Return current operation one of the following. - - ["eco", "performance", "heat_pump", - "high_demand", "electric_only", "gas] - """ - if not self.wink.is_on(): - current_op = STATE_OFF - else: - current_op = WINK_STATE_TO_HA.get(self.wink.current_mode()) - if current_op is None: - current_op = STATE_UNKNOWN - return current_op - - @property - def operation_list(self): - """List of available operation modes.""" - op_list = ['off'] - modes = self.wink.modes() - for mode in modes: - if mode == 'aux': - continue - ha_mode = WINK_STATE_TO_HA.get(mode) - if ha_mode is not None: - op_list.append(ha_mode) - else: - error = "Invalid operation mode mapping. " + mode + \ - " doesn't map. Please report this." - _LOGGER.error(error) - return op_list - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - target_temp = kwargs.get(ATTR_TEMPERATURE) - self.wink.set_temperature(target_temp) - - def set_operation_mode(self, operation_mode): - """Set operation mode.""" - op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode) - self.wink.set_operation_mode(op_mode_to_set) - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self.wink.current_set_point() - - def turn_away_mode_on(self): - """Turn away on.""" - self.wink.set_vacation_mode(True) - - def turn_away_mode_off(self): - """Turn away off.""" - self.wink.set_vacation_mode(False) - - @property - def min_temp(self): - """Return the minimum temperature.""" - return self.wink.min_set_point() - - @property - def max_temp(self): - """Return the maximum temperature.""" - return self.wink.max_set_point() diff --git a/homeassistant/components/climate/zhong_hong.py b/homeassistant/components/climate/zhong_hong.py index 46d590a9412..b564e9d1fa4 100644 --- a/homeassistant/components/climate/zhong_hong.py +++ b/homeassistant/components/climate/zhong_hong.py @@ -18,21 +18,23 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import (async_dispatcher_connect, async_dispatcher_send) +REQUIREMENTS = ['zhong_hong_hvac==1.0.9'] + _LOGGER = logging.getLogger(__name__) CONF_GATEWAY_ADDRRESS = 'gateway_address' -REQUIREMENTS = ['zhong_hong_hvac==1.0.9'] +DEFAULT_PORT = 9999 +DEFAULT_GATEWAY_ADDRRESS = 1 + SIGNAL_DEVICE_ADDED = 'zhong_hong_device_added' SIGNAL_ZHONG_HONG_HUB_START = 'zhong_hong_hub_start' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): - cv.string, - vol.Optional(CONF_PORT, default=9999): - vol.Coerce(int), - vol.Optional(CONF_GATEWAY_ADDRRESS, default=1): - vol.Coerce(int), + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_GATEWAY_ADDRRESS, default=DEFAULT_GATEWAY_ADDRRESS): + cv.positive_int, }) diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index 77b5e111686..561af9c9f57 100644 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -6,14 +6,15 @@ https://home-assistant.io/components/climate.zwave/ """ # Because we do not compile openzwave on CI import logging +from homeassistant.core import callback from homeassistant.components.climate import ( DOMAIN, ClimateDevice, STATE_AUTO, STATE_COOL, STATE_HEAT, SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE) -from homeassistant.components.zwave import ( # noqa pylint: disable=unused-import - ZWaveDeviceEntity, async_setup_platform) +from homeassistant.components.zwave import ZWaveDeviceEntity from homeassistant.const import ( STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) @@ -42,6 +43,22 @@ STATE_MAPPINGS = { } +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old method of setting up Z-Wave climate devices.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Z-Wave Climate device from Config Entry.""" + @callback + def async_add_climate(climate): + """Add Z-Wave Climate Device.""" + async_add_entities([climate]) + + async_dispatcher_connect(hass, 'zwave_new_climate', async_add_climate) + + def get_device(hass, values, **kwargs): """Create Z-Wave entity device.""" temp_unit = hass.config.units.temperature_unit diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 54a221565b4..3bfc5909b0b 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -162,7 +162,7 @@ class Cloud: @property def subscription_expired(self): """Return a boolean if the subscription has expired.""" - return dt_util.utcnow() > self.expiration_date + timedelta(days=3) + return dt_util.utcnow() > self.expiration_date + timedelta(days=7) @property def expiration_date(self): diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index dcf7567482a..042b90bf9cb 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -113,6 +113,24 @@ def check_token(cloud): raise _map_aws_exception(err) +def renew_access_token(cloud): + """Renew access token.""" + from botocore.exceptions import ClientError + + 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) + + def _authenticate(cloud, email, password): """Log in and return an authenticated Cognito instance.""" from botocore.exceptions import ClientError diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 720ca00cf52..0df4a39406e 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -14,7 +14,7 @@ from homeassistant.components import websocket_api from . import auth_api from .const import DOMAIN, REQUEST_TIMEOUT -from .iot import STATE_DISCONNECTED +from .iot import STATE_DISCONNECTED, STATE_CONNECTED _LOGGER = logging.getLogger(__name__) @@ -249,13 +249,28 @@ async def websocket_subscription(hass, connection, msg): with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): response = await cloud.fetch_subscription_info() - if response.status == 200: - connection.send_message(websocket_api.result_message( - msg['id'], await response.json())) - else: + if response.status != 200: connection.send_message(websocket_api.error_message( msg['id'], 'request_failed', 'Failed to request subscription')) + data = await response.json() + + # 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: + _LOGGER.debug( + "Found disconnected account with valid subscriotion, connecting") + await hass.async_add_executor_job( + auth_api.renew_access_token, cloud) + + # Cancel reconnect in progress + if cloud.iot.state != STATE_DISCONNECTED: + await cloud.iot.disconnect() + + hass.async_create_task(cloud.iot.connect()) + + connection.send_message(websocket_api.result_message(msg['id'], data)) + @websocket_api.async_response async def websocket_update_prefs(hass, connection, msg): diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py index fb60b4075ef..ec83918e9f0 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -92,6 +92,7 @@ def _user_info(user): 'is_owner': user.is_owner, 'is_active': user.is_active, 'system_generated': user.system_generated, + 'group_ids': [group.id for group in user.groups], 'credentials': [ { 'type': c.auth_provider_type, diff --git a/homeassistant/components/cover/deconz.py b/homeassistant/components/cover/deconz.py index 9fe65596336..89b29aa10a5 100644 --- a/homeassistant/components/cover/deconz.py +++ b/homeassistant/components/cover/deconz.py @@ -5,10 +5,10 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.deconz/ """ from homeassistant.components.deconz.const import ( - COVER_TYPES, DOMAIN as DATA_DECONZ, DATA_DECONZ_ID, DATA_DECONZ_UNSUB, - DECONZ_DOMAIN) + COVER_TYPES, DAMPERS, DOMAIN as DATA_DECONZ, DATA_DECONZ_ID, + DATA_DECONZ_UNSUB, DECONZ_DOMAIN, WINDOW_COVERS) from homeassistant.components.cover import ( - ATTR_POSITION, CoverDevice, SUPPORT_CLOSE, SUPPORT_OPEN, + ATTR_POSITION, CoverDevice, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_STOP, SUPPORT_SET_POSITION) from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE @@ -16,6 +16,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect DEPENDENCIES = ['deconz'] +ZIGBEE_SPEC = ['lumi.curtain'] + async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -34,7 +36,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] for light in lights: if light.type in COVER_TYPES: - entities.append(DeconzCover(light)) + if light.modelid in ZIGBEE_SPEC: + entities.append(DeconzCoverZigbeeSpec(light)) + else: + entities.append(DeconzCover(light)) async_add_entities(entities, True) hass.data[DATA_DECONZ_UNSUB].append( @@ -49,7 +54,10 @@ class DeconzCover(CoverDevice): def __init__(self, cover): """Set up cover and add update callback to get data from websocket.""" self._cover = cover - self._features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION + self._features = SUPPORT_OPEN + self._features |= SUPPORT_CLOSE + self._features |= SUPPORT_STOP + self._features |= SUPPORT_SET_POSITION async def async_added_to_hass(self): """Subscribe to covers events.""" @@ -91,7 +99,11 @@ class DeconzCover(CoverDevice): @property def device_class(self): """Return the class of the cover.""" - return 'damper' + if self._cover.type in DAMPERS: + return 'damper' + if self._cover.type in WINDOW_COVERS: + return 'window' + return None @property def supported_features(self): @@ -127,6 +139,11 @@ class DeconzCover(CoverDevice): data = {ATTR_POSITION: 0} await self.async_set_cover_position(**data) + async def async_stop_cover(self, **kwargs): + """Stop cover.""" + data = {'bri_inc': 0} + await self._cover.async_set_state(data) + @property def device_info(self): """Return a device description for device registry.""" @@ -144,3 +161,26 @@ class DeconzCover(CoverDevice): 'sw_version': self._cover.swversion, 'via_hub': (DECONZ_DOMAIN, bridgeid), } + + +class DeconzCoverZigbeeSpec(DeconzCover): + """Zigbee spec is the inverse of how deCONZ normally reports attributes.""" + + @property + def current_cover_position(self): + """Return the current position of the cover.""" + return 100 - int(self._cover.brightness / 255 * 100) + + @property + def is_closed(self): + """Return if the cover is closed.""" + return self._cover.state + + async def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + data = {'on': False} + if position < 100: + data['on'] = True + data['bri'] = 255 - int(position / 100 * 255) + await self._cover.async_set_state(data) diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index 43a87fab367..4173db5f450 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -64,7 +64,7 @@ def async_add_entities_discovery(hass, discovery_info, async_add_entities): entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] - entities.append(KNXCover(hass, device)) + entities.append(KNXCover(device)) async_add_entities(entities) @@ -88,18 +88,15 @@ def async_add_entities_config(hass, config, async_add_entities): invert_angle=config.get(CONF_INVERT_ANGLE)) hass.data[DATA_KNX].xknx.devices.add(cover) - async_add_entities([KNXCover(hass, cover)]) + async_add_entities([KNXCover(cover)]) class KNXCover(CoverDevice): """Representation of a KNX cover.""" - def __init__(self, hass, device): + def __init__(self, device): """Initialize the cover.""" self.device = device - self.hass = hass - self.async_register_callbacks() - self._unsubscribe_auto_updater = None @callback @@ -110,6 +107,10 @@ class KNXCover(CoverDevice): await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) + async def async_added_to_hass(self): + """Store register state change callback.""" + self.async_register_callbacks() + @property def name(self): """Return the name of the KNX device.""" diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index cbc8fbee274..92a7fac1d33 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -19,12 +19,12 @@ from homeassistant.components.cover import ( from homeassistant.exceptions import TemplateError from homeassistant.const import ( CONF_NAME, CONF_VALUE_TEMPLATE, CONF_OPTIMISTIC, STATE_OPEN, - STATE_CLOSED, STATE_UNKNOWN) + STATE_CLOSED, STATE_UNKNOWN, CONF_DEVICE) from homeassistant.components.mqtt import ( ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, valid_publish_topic, valid_subscribe_topic, - MqttAvailability, MqttDiscoveryUpdate) + MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo) from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -96,6 +96,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_TILT_INVERT_STATE, default=DEFAULT_TILT_INVERT_STATE): cv.boolean, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @@ -155,11 +156,13 @@ async def _async_setup_entity(hass, config, async_add_entities, config.get(CONF_POSITION_TOPIC), set_position_template, config.get(CONF_UNIQUE_ID), + config.get(CONF_DEVICE), discovery_hash )]) -class MqttCover(MqttAvailability, MqttDiscoveryUpdate, CoverDevice): +class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, + CoverDevice): """Representation of a cover that can be controlled using MQTT.""" def __init__(self, name, state_topic, command_topic, availability_topic, @@ -169,11 +172,13 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, CoverDevice): optimistic, value_template, tilt_open_position, tilt_closed_position, tilt_min, tilt_max, tilt_optimistic, tilt_invert, position_topic, set_position_template, - unique_id: Optional[str], discovery_hash): + unique_id: Optional[str], device_config: Optional[ConfigType], + discovery_hash): """Initialize the cover.""" MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) MqttDiscoveryUpdate.__init__(self, discovery_hash) + MqttEntityDeviceInfo.__init__(self, device_config) self._position = None self._state = None self._name = name diff --git a/homeassistant/components/cover/rflink.py b/homeassistant/components/cover/rflink.py index 41a4c2af045..353cccc7d4f 100644 --- a/homeassistant/components/cover/rflink.py +++ b/homeassistant/components/cover/rflink.py @@ -9,8 +9,9 @@ import logging import voluptuous as vol from homeassistant.components.rflink import ( - DATA_ENTITY_GROUP_LOOKUP, DATA_ENTITY_LOOKUP, - DEVICE_DEFAULTS_SCHEMA, EVENT_KEY_COMMAND, RflinkCommand) + CONF_ALIASES, CONF_DEVICE_DEFAULTS, CONF_DEVICES, CONF_FIRE_EVENT, + CONF_GROUP, CONF_GROUP_ALIASES, CONF_NOGROUP_ALIASES, + CONF_SIGNAL_REPETITIONS, DEVICE_DEFAULTS_SCHEMA, RflinkCommand) from homeassistant.components.cover import ( CoverDevice, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv @@ -22,19 +23,6 @@ DEPENDENCIES = ['rflink'] _LOGGER = logging.getLogger(__name__) -CONF_ALIASES = 'aliases' -CONF_GROUP_ALIASES = 'group_aliases' -CONF_GROUP = 'group' -CONF_NOGROUP_ALIASES = 'nogroup_aliases' -CONF_DEVICE_DEFAULTS = 'device_defaults' -CONF_DEVICES = 'devices' -CONF_AUTOMATIC_ADD = 'automatic_add' -CONF_FIRE_EVENT = 'fire_event' -CONF_IGNORE_DEVICES = 'ignore_devices' -CONF_RECONNECT_INTERVAL = 'reconnect_interval' -CONF_SIGNAL_REPETITIONS = 'signal_repetitions' -CONF_WAIT_FOR_ACK = 'wait_for_ack' - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({})): DEVICE_DEFAULTS_SCHEMA, @@ -55,33 +43,21 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def devices_from_config(domain_config, hass=None): +def devices_from_config(domain_config): """Parse configuration and add Rflink cover devices.""" devices = [] for device_id, config in domain_config[CONF_DEVICES].items(): device_config = dict(domain_config[CONF_DEVICE_DEFAULTS], **config) - device = RflinkCover(device_id, hass, **device_config) + device = RflinkCover(device_id, **device_config) devices.append(device) - # Register entity (and aliases) to listen to incoming rflink events - # Device id and normal aliases respond to normal and group command - hass.data[DATA_ENTITY_LOOKUP][ - EVENT_KEY_COMMAND][device_id].append(device) - if config[CONF_GROUP]: - hass.data[DATA_ENTITY_GROUP_LOOKUP][ - EVENT_KEY_COMMAND][device_id].append(device) - for _id in config[CONF_ALIASES]: - hass.data[DATA_ENTITY_LOOKUP][ - EVENT_KEY_COMMAND][_id].append(device) - hass.data[DATA_ENTITY_GROUP_LOOKUP][ - EVENT_KEY_COMMAND][_id].append(device) return devices async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Rflink cover platform.""" - async_add_entities(devices_from_config(config, hass)) + async_add_entities(devices_from_config(config)) class RflinkCover(RflinkCommand, CoverDevice): diff --git a/homeassistant/components/cover/ryobi_gdo.py b/homeassistant/components/cover/ryobi_gdo.py deleted file mode 100644 index fec91f843fd..00000000000 --- a/homeassistant/components/cover/ryobi_gdo.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -Ryobi platform for the cover component. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/cover.ryobi_gdo/ -""" -import logging -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv - -from homeassistant.components.cover import ( - CoverDevice, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE) -from homeassistant.const import ( - CONF_USERNAME, CONF_PASSWORD, STATE_UNKNOWN, STATE_CLOSED) - -REQUIREMENTS = ['py_ryobi_gdo==0.0.10'] - -_LOGGER = logging.getLogger(__name__) - -CONF_DEVICE_ID = 'device_id' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, -}) - -SUPPORTED_FEATURES = (SUPPORT_OPEN | SUPPORT_CLOSE) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Ryobi covers.""" - from py_ryobi_gdo import RyobiGDO as ryobi_door - covers = [] - - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - devices = config.get(CONF_DEVICE_ID) - - for device_id in devices: - my_door = ryobi_door(username, password, device_id) - _LOGGER.debug("Getting the API key") - if my_door.get_api_key() is False: - _LOGGER.error("Wrong credentials, no API key retrieved") - return - _LOGGER.debug("Checking if the device ID is present") - if my_door.check_device_id() is False: - _LOGGER.error("%s not in your device list", device_id) - return - _LOGGER.debug("Adding device %s to covers", device_id) - covers.append(RyobiCover(hass, my_door)) - if covers: - _LOGGER.debug("Adding covers") - add_entities(covers, True) - - -class RyobiCover(CoverDevice): - """Representation of a ryobi cover.""" - - def __init__(self, hass, ryobi_door): - """Initialize the cover.""" - self.ryobi_door = ryobi_door - self._name = 'ryobi_gdo_{}'.format(ryobi_door.get_device_id()) - self._door_state = None - - @property - def name(self): - """Return the name of the cover.""" - return self._name - - @property - def is_closed(self): - """Return if the cover is closed.""" - if self._door_state == STATE_UNKNOWN: - return False - return self._door_state == STATE_CLOSED - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return 'garage' - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORTED_FEATURES - - def close_cover(self, **kwargs): - """Close the cover.""" - _LOGGER.debug("Closing garage door") - self.ryobi_door.close_device() - - def open_cover(self, **kwargs): - """Open the cover.""" - _LOGGER.debug("Opening garage door") - self.ryobi_door.open_device() - - def update(self): - """Update status from the door.""" - _LOGGER.debug("Updating RyobiGDO status") - self.ryobi_door.update() - self._door_state = self.ryobi_door.get_door_status() diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py index e02cdc32319..f64e4ae7a3f 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -278,9 +278,10 @@ class CoverTemplate(CoverDevice): async def async_open_cover(self, **kwargs): """Move the cover up.""" if self._open_script: - await self._open_script.async_run() + await self._open_script.async_run(context=self._context) elif self._position_script: - await self._position_script.async_run({"position": 100}) + await self._position_script.async_run( + {"position": 100}, context=self._context) if self._optimistic: self._position = 100 self.async_schedule_update_ha_state() @@ -288,9 +289,10 @@ class CoverTemplate(CoverDevice): async def async_close_cover(self, **kwargs): """Move the cover down.""" if self._close_script: - await self._close_script.async_run() + await self._close_script.async_run(context=self._context) elif self._position_script: - await self._position_script.async_run({"position": 0}) + await self._position_script.async_run( + {"position": 0}, context=self._context) if self._optimistic: self._position = 0 self.async_schedule_update_ha_state() @@ -298,20 +300,21 @@ class CoverTemplate(CoverDevice): async def async_stop_cover(self, **kwargs): """Fire the stop action.""" if self._stop_script: - await self._stop_script.async_run() + await self._stop_script.async_run(context=self._context) async def async_set_cover_position(self, **kwargs): """Set cover position.""" self._position = kwargs[ATTR_POSITION] await self._position_script.async_run( - {"position": self._position}) + {"position": self._position}, context=self._context) if self._optimistic: self.async_schedule_update_ha_state() async def async_open_cover_tilt(self, **kwargs): """Tilt the cover open.""" self._tilt_value = 100 - await self._tilt_script.async_run({"tilt": self._tilt_value}) + await self._tilt_script.async_run( + {"tilt": self._tilt_value}, context=self._context) if self._tilt_optimistic: self.async_schedule_update_ha_state() @@ -319,14 +322,15 @@ class CoverTemplate(CoverDevice): """Tilt the cover closed.""" self._tilt_value = 0 await self._tilt_script.async_run( - {"tilt": self._tilt_value}) + {"tilt": self._tilt_value}, context=self._context) if self._tilt_optimistic: self.async_schedule_update_ha_state() async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" self._tilt_value = kwargs[ATTR_TILT_POSITION] - await self._tilt_script.async_run({"tilt": self._tilt_value}) + await self._tilt_script.async_run( + {"tilt": self._tilt_value}, context=self._context) if self._tilt_optimistic: self.async_schedule_update_ha_state() diff --git a/homeassistant/components/cover/zwave.py b/homeassistant/components/cover/zwave.py index 258087702e0..835305449e8 100644 --- a/homeassistant/components/cover/zwave.py +++ b/homeassistant/components/cover/zwave.py @@ -4,21 +4,37 @@ Support for Z-Wave cover components. For more details about this platform, please refer to the documentation https://home-assistant.io/components/cover.zwave/ """ -# Because we do not compile openzwave on CI -# pylint: disable=import-error import logging +from homeassistant.core import callback from homeassistant.components.cover import ( DOMAIN, SUPPORT_OPEN, SUPPORT_CLOSE, ATTR_POSITION) from homeassistant.components import zwave -from homeassistant.components.zwave import ( # noqa pylint: disable=unused-import - ZWaveDeviceEntity, async_setup_platform, workaround) +from homeassistant.components.zwave import ( + ZWaveDeviceEntity, workaround) from homeassistant.components.cover import CoverDevice +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) SUPPORT_GARAGE = SUPPORT_OPEN | SUPPORT_CLOSE +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old method of setting up Z-Wave covers.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Z-Wave Cover from Config Entry.""" + @callback + def async_add_cover(cover): + """Add Z-Wave Cover.""" + async_add_entities([cover]) + + async_dispatcher_connect(hass, 'zwave_new_cover', async_add_cover) + + def get_device(hass, values, node_config, **kwargs): """Create Z-Wave entity device.""" invert_buttons = node_config.get(zwave.CONF_INVERT_OPENCLOSE_BUTTONS) diff --git a/homeassistant/components/deconz/.translations/pt.json b/homeassistant/components/deconz/.translations/pt.json index 1f7b8209089..eef2d5ce946 100644 --- a/homeassistant/components/deconz/.translations/pt.json +++ b/homeassistant/components/deconz/.translations/pt.json @@ -28,6 +28,6 @@ "title": "Op\u00e7\u00f5es extra de configura\u00e7\u00e3o para deCONZ" } }, - "title": "deCONZ" + "title": "Gateway Zigbee deCONZ" } } \ No newline at end of file diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index 617d231f92e..5462b5b61b9 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -16,7 +16,9 @@ CONF_ALLOW_DECONZ_GROUPS = 'allow_deconz_groups' ATTR_DARK = 'dark' ATTR_ON = 'on' -COVER_TYPES = ["Level controllable output"] +DAMPERS = ["Level controllable output"] +WINDOW_COVERS = ["Window covering device"] +COVER_TYPES = DAMPERS + WINDOW_COVERS POWER_PLUGS = ["On/Off plug-in unit", "Smart plug"] SIRENS = ["Warning device"] diff --git a/homeassistant/components/device_tracker/ddwrt.py b/homeassistant/components/device_tracker/ddwrt.py index 539d4fde5ef..cf8c8e1779b 100644 --- a/homeassistant/components/device_tracker/ddwrt.py +++ b/homeassistant/components/device_tracker/ddwrt.py @@ -10,20 +10,26 @@ import re import requests 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_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) _DDWRT_DATA_REGEX = re.compile(r'\{(\w+)::([^\}]*)\}') _MAC_REGEX = re.compile(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})') +DEFAULT_SSL = False +DEFAULT_VERIFY_SSL = True + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, }) @@ -39,7 +45,9 @@ class DdWrtDeviceScanner(DeviceScanner): """This class queries a wireless router running DD-WRT firmware.""" def __init__(self, config): - """Initialize the scanner.""" + """Initialize the DD-WRT scanner.""" + self.protocol = 'https' if config[CONF_SSL] else 'http' + self.verify_ssl = config[CONF_VERIFY_SSL] self.host = config[CONF_HOST] self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] @@ -48,7 +56,8 @@ class DdWrtDeviceScanner(DeviceScanner): self.mac2name = {} # Test the router is accessible - url = 'http://{}/Status_Wireless.live.asp'.format(self.host) + url = '{}://{}/Status_Wireless.live.asp'.format( + self.protocol, self.host) data = self.get_ddwrt_data(url) if not data: raise ConnectionError('Cannot connect to DD-Wrt router') @@ -63,7 +72,8 @@ class DdWrtDeviceScanner(DeviceScanner): """Return the name of the given device or None if we don't know.""" # If not initialised and not already scanned and not found. if device not in self.mac2name: - url = 'http://{}/Status_Lan.live.asp'.format(self.host) + url = '{}://{}/Status_Lan.live.asp'.format( + self.protocol, self.host) data = self.get_ddwrt_data(url) if not data: @@ -98,7 +108,8 @@ class DdWrtDeviceScanner(DeviceScanner): """ _LOGGER.info("Checking ARP") - url = 'http://{}/Status_Wireless.live.asp'.format(self.host) + url = '{}://{}/Status_Wireless.live.asp'.format( + self.protocol, self.host) data = self.get_ddwrt_data(url) if not data: @@ -125,7 +136,8 @@ class DdWrtDeviceScanner(DeviceScanner): """Retrieve data from DD-WRT and return parsed result.""" try: response = requests.get( - url, auth=(self.username, self.password), timeout=4) + url, auth=(self.username, self.password), + timeout=4, verify=self.verify_ssl) except requests.exceptions.Timeout: _LOGGER.exception("Connection to the router timed out") return @@ -142,5 +154,4 @@ class DdWrtDeviceScanner(DeviceScanner): def _parse_ddwrt_response(data_str): """Parse the DD-WRT data format.""" return { - key: val for key, val in _DDWRT_DATA_REGEX - .findall(data_str)} + key: val for key, val in _DDWRT_DATA_REGEX.findall(data_str)} diff --git a/homeassistant/components/device_tracker/fritz.py b/homeassistant/components/device_tracker/fritz.py index 8c9d1988a71..75e280fe908 100644 --- a/homeassistant/components/device_tracker/fritz.py +++ b/homeassistant/components/device_tracker/fritz.py @@ -43,8 +43,7 @@ class FritzBoxScanner(DeviceScanner): self.password = config[CONF_PASSWORD] self.success_init = True - # pylint: disable=import-error - import fritzconnection as fc + import fritzconnection as fc # pylint: disable=import-error # Establish a connection to the FRITZ!Box. try: diff --git a/homeassistant/components/device_tracker/google_maps.py b/homeassistant/components/device_tracker/google_maps.py index 77f499dcf6b..aec1dcff355 100644 --- a/homeassistant/components/device_tracker/google_maps.py +++ b/homeassistant/components/device_tracker/google_maps.py @@ -19,7 +19,7 @@ from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify, dt as dt_util -REQUIREMENTS = ['locationsharinglib==3.0.3'] +REQUIREMENTS = ['locationsharinglib==3.0.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py index 2e1b96dffad..12d026a35cd 100644 --- a/homeassistant/components/device_tracker/netgear.py +++ b/homeassistant/components/device_tracker/netgear.py @@ -15,7 +15,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_SSL, CONF_DEVICES, CONF_EXCLUDE) -REQUIREMENTS = ['pynetgear==0.4.2'] +REQUIREMENTS = ['pynetgear==0.5.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/quantum_gateway.py b/homeassistant/components/device_tracker/quantum_gateway.py new file mode 100644 index 00000000000..a06794f9179 --- /dev/null +++ b/homeassistant/components/device_tracker/quantum_gateway.py @@ -0,0 +1,69 @@ +""" +Support for Verizon FiOS Quantum Gateways. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.quantum_gateway/ +""" +import logging + +from requests.exceptions import RequestException +import voluptuous as vol + +from homeassistant.components.device_tracker import (DOMAIN, PLATFORM_SCHEMA, + DeviceScanner) +from homeassistant.const import (CONF_HOST, CONF_PASSWORD) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['quantum-gateway==0.0.3'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_HOST = 'myfiosgateway.com' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string +}) + + +def get_scanner(hass, config): + """Validate the configuration and return a Quantum Gateway scanner.""" + scanner = QuantumGatewayDeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +class QuantumGatewayDeviceScanner(DeviceScanner): + """This class queries a Quantum Gateway.""" + + def __init__(self, config): + """Initialize the scanner.""" + from quantum_gateway import QuantumGatewayScanner + + self.host = config[CONF_HOST] + self.password = config[CONF_PASSWORD] + _LOGGER.debug('Initializing') + + try: + self.quantum = QuantumGatewayScanner(self.host, self.password) + self.success_init = self.quantum.success_init + except RequestException: + self.success_init = False + _LOGGER.error("Unable to connect to gateway. Check host.") + + if not self.success_init: + _LOGGER.error("Unable to login to gateway. Check password and " + "host.") + + def scan_devices(self): + """Scan for new devices and return a list of found MACs.""" + connected_devices = [] + try: + connected_devices = self.quantum.scan_devices() + except RequestException: + _LOGGER.error("Unable to scan devices. Check connection to router") + 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.quantum.get_device_name(device) diff --git a/homeassistant/components/device_tracker/unifi_direct.py b/homeassistant/components/device_tracker/unifi_direct.py index 228443fe22b..3b5dcc8bac2 100644 --- a/homeassistant/components/device_tracker/unifi_direct.py +++ b/homeassistant/components/device_tracker/unifi_direct.py @@ -86,10 +86,9 @@ class UnifiDeviceScanner(DeviceScanner): def _disconnect(self): """Disconnect the current SSH connection.""" - # pylint: disable=broad-except try: self.ssh.logout() - except Exception: + except Exception: # pylint: disable=broad-except pass finally: self.ssh = None diff --git a/homeassistant/components/device_tracker/xiaomi_miio.py b/homeassistant/components/device_tracker/xiaomi_miio.py index b1cfc0aed4a..d8767a4cd46 100644 --- a/homeassistant/components/device_tracker/xiaomi_miio.py +++ b/homeassistant/components/device_tracker/xiaomi_miio.py @@ -8,10 +8,12 @@ import logging import voluptuous as vol +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import CONF_HOST, CONF_TOKEN import homeassistant.helpers.config_validation as cv -from homeassistant.components.device_tracker import (DOMAIN, PLATFORM_SCHEMA, - DeviceScanner) -from homeassistant.const import (CONF_HOST, CONF_TOKEN) + +REQUIREMENTS = ['python-miio==0.4.2', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) @@ -20,8 +22,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), }) -REQUIREMENTS = ['python-miio==0.4.1', 'construct==2.9.41'] - def get_scanner(hass, config): """Return a Xiaomi MiIO device scanner.""" @@ -56,7 +56,7 @@ class XiaomiMiioDeviceScanner(DeviceScanner): self.device = device async def async_scan_devices(self): - """Scan for devices and return a list containing found device ids.""" + """Scan for devices and return a list containing found device IDs.""" from miio import DeviceException devices = [] @@ -68,7 +68,7 @@ class XiaomiMiioDeviceScanner(DeviceScanner): devices.append(device['mac']) except DeviceException as ex: - _LOGGER.error("Got exception while fetching the state: %s", ex) + _LOGGER.error("Unable to fetch the state: %s", ex) return devices diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 0640eb262cd..d7bb966a2d3 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -21,7 +21,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.1.0'] +REQUIREMENTS = ['netdisco==2.2.0'] DOMAIN = 'discovery' @@ -43,6 +43,7 @@ SERVICE_DAIKIN = 'daikin' SERVICE_SABNZBD = 'sabnzbd' SERVICE_SAMSUNG_PRINTER = 'samsung_printer' SERVICE_HOMEKIT = 'homekit' +SERVICE_OCTOPRINT = 'octoprint' CONFIG_ENTRY_HANDLERS = { SERVICE_DECONZ: 'deconz', @@ -67,6 +68,7 @@ SERVICE_HANDLERS = { SERVICE_SABNZBD: ('sabnzbd', None), SERVICE_SAMSUNG_PRINTER: ('sensor', 'syncthru'), SERVICE_KONNECTED: ('konnected', None), + SERVICE_OCTOPRINT: ('octoprint', None), 'panasonic_viera': ('media_player', 'panasonic_viera'), 'plex_mediaserver': ('media_player', 'plex'), 'roku': ('media_player', 'roku'), @@ -84,6 +86,7 @@ SERVICE_HANDLERS = { 'songpal': ('media_player', 'songpal'), 'kodi': ('media_player', 'kodi'), 'volumio': ('media_player', 'volumio'), + 'lg_smart_device': ('media_player', 'lg_soundbar'), 'nanoleaf_aurora': ('light', 'nanoleaf_aurora'), 'freebox': ('device_tracker', 'freebox'), } diff --git a/homeassistant/components/dyson.py b/homeassistant/components/dyson.py index 3989c0bbe3e..791f990d9ad 100644 --- a/homeassistant/components/dyson.py +++ b/homeassistant/components/dyson.py @@ -102,5 +102,6 @@ def setup(hass, config): discovery.load_platform(hass, "sensor", DOMAIN, {}, config) discovery.load_platform(hass, "fan", DOMAIN, {}, config) discovery.load_platform(hass, "vacuum", DOMAIN, {}, config) + discovery.load_platform(hass, "climate", DOMAIN, {}, config) return True diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py new file mode 100644 index 00000000000..76594e16736 --- /dev/null +++ b/homeassistant/components/elkm1/__init__.py @@ -0,0 +1,232 @@ +""" +Support the ElkM1 Gold and ElkM1 EZ8 alarm / integration panels. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/elkm1/ +""" + +import logging +import re + +import voluptuous as vol +from homeassistant.const import ( + CONF_EXCLUDE, CONF_HOST, CONF_INCLUDE, CONF_PASSWORD, + CONF_TEMPERATURE_UNIT, CONF_USERNAME, TEMP_FAHRENHEIT) +from homeassistant.core import HomeAssistant, callback # noqa +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType # noqa + +DOMAIN = "elkm1" + +REQUIREMENTS = ['elkm1-lib==0.7.10'] + +CONF_AREA = 'area' +CONF_COUNTER = 'counter' +CONF_KEYPAD = 'keypad' +CONF_OUTPUT = 'output' +CONF_SETTING = 'setting' +CONF_TASK = 'task' +CONF_THERMOSTAT = 'thermostat' +CONF_PLC = 'plc' +CONF_ZONE = 'zone' +CONF_ENABLED = 'enabled' + +_LOGGER = logging.getLogger(__name__) + +SUPPORTED_DOMAINS = ['alarm_control_panel', 'climate', 'light', 'scene', + 'sensor', 'switch'] + +SPEAK_SERVICE_SCHEMA = vol.Schema({ + vol.Required('number'): + vol.All(vol.Coerce(int), vol.Range(min=0, max=999)) +}) + + +def _host_validator(config): + """Validate that a host is properly configured.""" + if config[CONF_HOST].startswith('elks://'): + if CONF_USERNAME not in config or CONF_PASSWORD not in config: + raise vol.Invalid("Specify username and password for elks://") + elif not config[CONF_HOST].startswith('elk://') and not config[ + CONF_HOST].startswith('serial://'): + raise vol.Invalid("Invalid host URL") + return config + + +def _elk_range_validator(rng): + def _housecode_to_int(val): + match = re.search(r'^([a-p])(0[1-9]|1[0-6]|[1-9])$', val.lower()) + if match: + return (ord(match.group(1)) - ord('a')) * 16 + int(match.group(2)) + raise vol.Invalid("Invalid range") + + def _elk_value(val): + return int(val) if val.isdigit() else _housecode_to_int(val) + + vals = [s.strip() for s in str(rng).split('-')] + start = _elk_value(vals[0]) + end = start if len(vals) == 1 else _elk_value(vals[1]) + return (start, end) + + +CONFIG_SCHEMA_SUBDOMAIN = vol.Schema({ + vol.Optional(CONF_ENABLED, default=True): cv.boolean, + vol.Optional(CONF_INCLUDE, default=[]): [_elk_range_validator], + vol.Optional(CONF_EXCLUDE, default=[]): [_elk_range_validator], +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_USERNAME, default=''): cv.string, + vol.Optional(CONF_PASSWORD, default=''): cv.string, + vol.Optional(CONF_TEMPERATURE_UNIT, default=TEMP_FAHRENHEIT): + cv.temperature_unit, + vol.Optional(CONF_AREA): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_COUNTER): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_KEYPAD): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_OUTPUT): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_PLC): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_SETTING): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_TASK): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_THERMOSTAT): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_ZONE): CONFIG_SCHEMA_SUBDOMAIN, + }, + _host_validator, + ) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: + """Set up the Elk M1 platform.""" + from elkm1_lib.const import Max + import elkm1_lib as elkm1 + + configs = { + CONF_AREA: Max.AREAS.value, + CONF_COUNTER: Max.COUNTERS.value, + CONF_KEYPAD: Max.KEYPADS.value, + CONF_OUTPUT: Max.OUTPUTS.value, + CONF_PLC: Max.LIGHTS.value, + CONF_SETTING: Max.SETTINGS.value, + CONF_TASK: Max.TASKS.value, + CONF_THERMOSTAT: Max.THERMOSTATS.value, + CONF_ZONE: Max.ZONES.value, + } + + def _included(ranges, set_to, values): + for rng in ranges: + if not rng[0] <= rng[1] <= len(values): + raise vol.Invalid("Invalid range {}".format(rng)) + values[rng[0]-1:rng[1]] = [set_to] * (rng[1] - rng[0] + 1) + + conf = hass_config[DOMAIN] + config = {'temperature_unit': conf[CONF_TEMPERATURE_UNIT]} + config['panel'] = {'enabled': True, 'included': [True]} + + for item, max_ in configs.items(): + config[item] = {'enabled': conf[item][CONF_ENABLED], + 'included': [not conf[item]['include']] * max_} + try: + _included(conf[item]['include'], True, config[item]['included']) + _included(conf[item]['exclude'], False, config[item]['included']) + except (ValueError, vol.Invalid) as err: + _LOGGER.error("Config item: %s; %s", item, err) + return False + + elk = elkm1.Elk({'url': conf[CONF_HOST], 'userid': conf[CONF_USERNAME], + 'password': conf[CONF_PASSWORD]}) + elk.connect() + + _create_elk_services(hass, elk) + + hass.data[DOMAIN] = {'elk': elk, 'config': config, 'keypads': {}} + for component in SUPPORTED_DOMAINS: + hass.async_create_task( + discovery.async_load_platform(hass, component, DOMAIN, {})) + + return True + + +def _create_elk_services(hass, elk): + def _speak_word_service(service): + elk.panel.speak_word(service.data.get('number')) + + def _speak_phrase_service(service): + elk.panel.speak_phrase(service.data.get('number')) + + hass.services.async_register( + DOMAIN, 'speak_word', _speak_word_service, SPEAK_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, 'speak_phrase', _speak_phrase_service, SPEAK_SERVICE_SCHEMA) + + +def create_elk_entities(hass, elk_elements, element_type, class_, entities): + """Create the ElkM1 devices of a particular class.""" + elk_data = hass.data[DOMAIN] + if elk_data['config'][element_type]['enabled']: + elk = elk_data['elk'] + for element in elk_elements: + if elk_data['config'][element_type]['included'][element.index]: + entities.append(class_(element, elk, elk_data)) + return entities + + +class ElkEntity(Entity): + """Base class for all Elk entities.""" + + def __init__(self, element, elk, elk_data): + """Initialize the base of all Elk devices.""" + self._elk = elk + self._element = element + self._temperature_unit = elk_data['config']['temperature_unit'] + self._unique_id = 'elkm1_{}'.format( + self._element.default_name('_').lower()) + + @property + def name(self): + """Name of the element.""" + return self._element.name + + @property + def unique_id(self): + """Return unique id of the element.""" + return self._unique_id + + @property + def should_poll(self) -> bool: + """Don't poll this device.""" + return False + + @property + def device_state_attributes(self): + """Return the default attributes of the element.""" + return {**self._element.as_dict(), **self.initial_attrs()} + + @property + def available(self): + """Is the entity available to be updated.""" + return self._elk.is_connected() + + def initial_attrs(self): + """Return the underlying element's attributes as a dict.""" + attrs = {} + attrs['index'] = self._element.index + 1 + return attrs + + def _element_changed(self, element, changeset): + pass + + @callback + def _element_callback(self, element, changeset): + """Handle callback from an Elk element that has changed.""" + self._element_changed(element, changeset) + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Register callback for ElkM1 changes and update entity state.""" + self._element.add_callback(self._element_callback) + self._element_callback(self._element, {}) diff --git a/homeassistant/components/elkm1/services.yaml b/homeassistant/components/elkm1/services.yaml new file mode 100644 index 00000000000..40571656963 --- /dev/null +++ b/homeassistant/components/elkm1/services.yaml @@ -0,0 +1,12 @@ +speak_word: + description: Speak a word. See list of words in ElkM1 ASCII Protocol documentation. + fields: + number: + description: Word number to speak. + example: 142 +speak_phrase: + description: Speak a phrase. See list of phrases in ElkM1 ASCII Protocol documentation. + fields: + number: + description: Phrase number to speak. + example: 42 diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index 3e1ad2704e7..1ff04cd913a 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -10,20 +10,23 @@ from typing import Optional import voluptuous as vol from homeassistant.core import callback -from homeassistant.components import mqtt +from homeassistant.components import fan, mqtt from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, STATE_ON, STATE_OFF, - CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON) + CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, CONF_DEVICE) from homeassistant.components.mqtt import ( ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, - CONF_QOS, CONF_RETAIN, MqttAvailability, MqttDiscoveryUpdate) + CONF_QOS, CONF_RETAIN, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType, ConfigType from homeassistant.components.fan import (SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, FanEntity, SUPPORT_SET_SPEED, SUPPORT_OSCILLATE, SPEED_OFF, ATTR_SPEED) +from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW _LOGGER = logging.getLogger(__name__) @@ -77,19 +80,32 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ SPEED_MEDIUM, SPEED_HIGH]): cv.ensure_list, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None): - """Set up the MQTT fan platform.""" - if discovery_info is not None: - config = PLATFORM_SCHEMA(discovery_info) + """Set up MQTT fan through configuration.yaml.""" + await _async_setup_entity(hass, config, async_add_entities) - discovery_hash = None - if discovery_info is not None and ATTR_DISCOVERY_HASH in discovery_info: - discovery_hash = discovery_info[ATTR_DISCOVERY_HASH] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up MQTT fan dynamically through MQTT discovery.""" + async def async_discover(discovery_payload): + """Discover and add a MQTT fan.""" + config = PLATFORM_SCHEMA(discovery_payload) + await _async_setup_entity(hass, config, async_add_entities, + discovery_payload[ATTR_DISCOVERY_HASH]) + + async_dispatcher_connect( + hass, MQTT_DISCOVERY_NEW.format(fan.DOMAIN, 'mqtt'), + async_discover) + + +async def _async_setup_entity(hass, config, async_add_entities, + discovery_hash=None): + """Set up the MQTT fan.""" async_add_entities([MqttFan( config.get(CONF_NAME), { @@ -124,21 +140,24 @@ async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, config.get(CONF_PAYLOAD_AVAILABLE), config.get(CONF_PAYLOAD_NOT_AVAILABLE), config.get(CONF_UNIQUE_ID), + config.get(CONF_DEVICE), discovery_hash, )]) -class MqttFan(MqttAvailability, MqttDiscoveryUpdate, FanEntity): +class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, + FanEntity): """A MQTT fan component.""" def __init__(self, name, topic, templates, qos, retain, payload, speed_list, optimistic, availability_topic, payload_available, payload_not_available, unique_id: Optional[str], - discovery_hash): + device_config: Optional[ConfigType], discovery_hash): """Initialize the MQTT fan.""" MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) MqttDiscoveryUpdate.__init__(self, discovery_hash) + MqttEntityDeviceInfo.__init__(self, device_config) self._name = name self._topic = topic self._qos = qos diff --git a/homeassistant/components/fan/template.py b/homeassistant/components/fan/template.py index ff25afb792a..a2f33d40e48 100644 --- a/homeassistant/components/fan/template.py +++ b/homeassistant/components/fan/template.py @@ -224,7 +224,7 @@ class TemplateFan(FanEntity): # pylint: disable=arguments-differ async def async_turn_on(self, speed: str = None) -> None: """Turn on the fan.""" - await self._on_script.async_run() + await self._on_script.async_run(context=self._context) self._state = STATE_ON if speed is not None: @@ -233,7 +233,7 @@ class TemplateFan(FanEntity): # pylint: disable=arguments-differ async def async_turn_off(self) -> None: """Turn off the fan.""" - await self._off_script.async_run() + await self._off_script.async_run(context=self._context) self._state = STATE_OFF async def async_set_speed(self, speed: str) -> None: @@ -243,7 +243,8 @@ class TemplateFan(FanEntity): if speed in self._speed_list: self._speed = speed - await self._set_speed_script.async_run({ATTR_SPEED: speed}) + await self._set_speed_script.async_run( + {ATTR_SPEED: speed}, context=self._context) else: _LOGGER.error( 'Received invalid speed: %s. Expected: %s.', @@ -257,7 +258,7 @@ class TemplateFan(FanEntity): if oscillating in _VALID_OSC: self._oscillating = oscillating await self._set_oscillating_script.async_run( - {ATTR_OSCILLATING: oscillating}) + {ATTR_OSCILLATING: oscillating}, context=self._context) else: _LOGGER.error( 'Received invalid oscillating value: %s. Expected: %s.', @@ -271,7 +272,7 @@ class TemplateFan(FanEntity): if direction in _VALID_DIRECTIONS: self._direction = direction await self._set_direction_script.async_run( - {ATTR_DIRECTION: direction}) + {ATTR_DIRECTION: direction}, context=self._context) else: _LOGGER.error( 'Received invalid direction: %s. Expected: %s.', diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index a66e833b4b2..67a12442629 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -11,13 +11,15 @@ import logging import voluptuous as vol -from homeassistant.components.fan import (FanEntity, PLATFORM_SCHEMA, - SUPPORT_SET_SPEED, DOMAIN, ) -from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, - ATTR_ENTITY_ID, ) +from homeassistant.components.fan import ( + DOMAIN, PLATFORM_SCHEMA, SUPPORT_SET_SPEED, FanEntity) +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN) from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv +REQUIREMENTS = ['python-miio==0.4.2', 'construct==2.9.45'] + _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Xiaomi Miio Device' @@ -49,8 +51,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 'zhimi.humidifier.ca1']), }) -REQUIREMENTS = ['python-miio==0.4.1', 'construct==2.9.41'] - ATTR_MODEL = 'model' # Air Purifier diff --git a/homeassistant/components/fan/zwave.py b/homeassistant/components/fan/zwave.py index 645cb033e13..4b4204aa454 100644 --- a/homeassistant/components/fan/zwave.py +++ b/homeassistant/components/fan/zwave.py @@ -7,11 +7,12 @@ https://home-assistant.io/components/fan.zwave/ import logging import math +from homeassistant.core import callback from homeassistant.components.fan import ( DOMAIN, FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED) from homeassistant.components import zwave -from homeassistant.components.zwave import async_setup_platform # noqa pylint: disable=unused-import +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) @@ -35,6 +36,22 @@ SPEED_TO_VALUE = { } +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old method of setting up Z-Wave fans.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Z-Wave Fan from Config Entry.""" + @callback + def async_add_fan(fan): + """Add Z-Wave Fan.""" + async_add_entities([fan]) + + async_dispatcher_connect(hass, 'zwave_new_fan', async_add_fan) + + def get_device(values, **kwargs): """Create Z-Wave entity device.""" return ZwaveFan(values) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index df25803b4e0..0e92595ae78 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181018.0'] +REQUIREMENTS = ['home-assistant-frontend==20181026.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py index 66753aad221..495c9e1744b 100644 --- a/homeassistant/components/geo_location/__init__.py +++ b/homeassistant/components/geo_location/__init__.py @@ -1,32 +1,34 @@ """ Geo Location component. -This component covers platforms that deal with external events that contain -a geo location related to the installed HA instance. - For more details about this component, please refer to the documentation at https://home-assistant.io/components/geo_location/ """ -import logging from datetime import timedelta +import logging from typing import Optional from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa _LOGGER = logging.getLogger(__name__) ATTR_DISTANCE = 'distance' +ATTR_SOURCE = 'source' + DOMAIN = 'geo_location' + ENTITY_ID_FORMAT = DOMAIN + '.{}' + GROUP_NAME_ALL_EVENTS = 'All Geo Location Events' + SCAN_INTERVAL = timedelta(seconds=60) async def async_setup(hass, config): - """Set up this component.""" + """Set up the Geo Location component.""" component = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_EVENTS) await component.async_setup(config) @@ -43,6 +45,11 @@ class GeoLocationEvent(Entity): return round(self.distance, 1) return None + @property + def source(self) -> str: + """Return source value of this external event.""" + raise NotImplementedError + @property def distance(self) -> Optional[float]: """Return distance value of this external event.""" @@ -66,4 +73,6 @@ class GeoLocationEvent(Entity): data[ATTR_LATITUDE] = round(self.latitude, 5) if self.longitude is not None: data[ATTR_LONGITUDE] = round(self.longitude, 5) + if self.source is not None: + data[ATTR_SOURCE] = self.source return data diff --git a/homeassistant/components/geo_location/demo.py b/homeassistant/components/geo_location/demo.py index ddec369e696..6c5cc2fe147 100644 --- a/homeassistant/components/geo_location/demo.py +++ b/homeassistant/components/geo_location/demo.py @@ -4,10 +4,10 @@ Demo platform for the geo location component. For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ -import logging -import random from datetime import timedelta -from math import pi, cos, sin, radians +import logging +from math import cos, pi, radians, sin +import random from typing import Optional from homeassistant.components.geo_location import GeoLocationEvent @@ -16,7 +16,7 @@ from homeassistant.helpers.event import track_time_interval _LOGGER = logging.getLogger(__name__) AVG_KM_PER_DEGREE = 111.0 -DEFAULT_UNIT_OF_MEASUREMENT = "km" +DEFAULT_UNIT_OF_MEASUREMENT = 'km' DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1) MAX_RADIUS_IN_KM = 50 NUMBER_OF_DEMO_DEVICES = 5 @@ -26,6 +26,8 @@ EVENT_NAMES = ["Bushfire", "Hazard Reduction", "Grass Fire", "Burn off", "Cyclone", "Waterspout", "Dust Storm", "Blizzard", "Ice Storm", "Earthquake", "Tsunami"] +SOURCE = 'demo' + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Demo geo locations.""" @@ -100,6 +102,11 @@ class DemoGeoLocationEvent(GeoLocationEvent): self._longitude = longitude self._unit_of_measurement = unit_of_measurement + @property + def source(self) -> str: + """Return source value of this external event.""" + return SOURCE + @property def name(self) -> Optional[str]: """Return the name of the event.""" diff --git a/homeassistant/components/geo_location/geo_json_events.py b/homeassistant/components/geo_location/geo_json_events.py index bb17fb2450e..00ac85e6b27 100644 --- a/homeassistant/components/geo_location/geo_json_events.py +++ b/homeassistant/components/geo_location/geo_json_events.py @@ -1,24 +1,23 @@ """ Generic GeoJSON events platform. -Retrieves current events (typically incidents or alerts) in GeoJSON format, and -displays information on events filtered by distance to the HA instance's -location. - For more details about this platform, please refer to the documentation at https://home-assistant.io/components/geo_location/geo_json_events/ """ -import logging from datetime import timedelta +import logging from typing import Optional import voluptuous as vol +from homeassistant.components.geo_location import ( + PLATFORM_SCHEMA, GeoLocationEvent) +from homeassistant.const import ( + CONF_RADIUS, CONF_SCAN_INTERVAL, CONF_URL, EVENT_HOMEASSISTANT_START) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.components.geo_location import GeoLocationEvent -from homeassistant.const import CONF_RADIUS, CONF_URL, CONF_SCAN_INTERVAL, \ - EVENT_HOMEASSISTANT_START -from homeassistant.components.geo_location import PLATFORM_SCHEMA +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, dispatcher_send) from homeassistant.helpers.event import track_time_interval REQUIREMENTS = ['geojson_client==0.1'] @@ -28,14 +27,18 @@ _LOGGER = logging.getLogger(__name__) ATTR_EXTERNAL_ID = 'external_id' DEFAULT_RADIUS_IN_KM = 20.0 -DEFAULT_UNIT_OF_MEASUREMENT = "km" +DEFAULT_UNIT_OF_MEASUREMENT = 'km' SCAN_INTERVAL = timedelta(minutes=5) +SIGNAL_DELETE_ENTITY = 'geo_json_events_delete_{}' +SIGNAL_UPDATE_ENTITY = 'geo_json_events_update_{}' + +SOURCE = 'geo_json_events' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_URL): cv.string, - vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): - vol.Coerce(float), + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), }) @@ -45,7 +48,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): scan_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) radius_in_km = config[CONF_RADIUS] # Initialize the entity manager. - GeoJsonFeedManager(hass, add_entities, scan_interval, url, radius_in_km) + feed = GeoJsonFeedManager(hass, add_entities, scan_interval, url, + radius_in_km) + + def start_feed_manager(event): + """Start feed manager.""" + feed.startup() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) class GeoJsonFeedManager: @@ -54,94 +64,117 @@ class GeoJsonFeedManager: def __init__(self, hass, add_entities, scan_interval, url, radius_in_km): """Initialize the GeoJSON Feed Manager.""" from geojson_client.generic_feed import GenericFeed + self._hass = hass - self._feed = GenericFeed((hass.config.latitude, hass.config.longitude), - filter_radius=radius_in_km, url=url) + self._feed = GenericFeed( + (hass.config.latitude, hass.config.longitude), + filter_radius=radius_in_km, url=url) self._add_entities = add_entities self._scan_interval = scan_interval - self._feed_entries = [] - self._managed_entities = [] - hass.bus.listen_once( - EVENT_HOMEASSISTANT_START, lambda _: self._update()) + self.feed_entries = {} + self._managed_external_ids = set() + + def startup(self): + """Start up this manager.""" + self._update() self._init_regular_updates() def _init_regular_updates(self): """Schedule regular updates at the specified interval.""" - track_time_interval(self._hass, lambda now: self._update(), - self._scan_interval) + track_time_interval( + self._hass, lambda now: self._update(), self._scan_interval) def _update(self): """Update the feed and then update connected entities.""" import geojson_client + status, feed_entries = self._feed.update() if status == geojson_client.UPDATE_OK: _LOGGER.debug("Data retrieved %s", feed_entries) # Keep a copy of all feed entries for future lookups by entities. - self._feed_entries = feed_entries.copy() - keep_entries = self._update_or_remove_entities(feed_entries) - self._generate_new_entities(keep_entries) + self.feed_entries = {entry.external_id: entry + for entry in feed_entries} + # For entity management the external ids from the feed are used. + feed_external_ids = set(self.feed_entries) + remove_external_ids = self._managed_external_ids.difference( + feed_external_ids) + self._remove_entities(remove_external_ids) + update_external_ids = self._managed_external_ids.intersection( + feed_external_ids) + self._update_entities(update_external_ids) + create_external_ids = feed_external_ids.difference( + self._managed_external_ids) + self._generate_new_entities(create_external_ids) elif status == geojson_client.UPDATE_OK_NO_DATA: - _LOGGER.debug("Update successful, but no data received from %s", - self._feed) + _LOGGER.debug( + "Update successful, but no data received from %s", self._feed) else: - _LOGGER.warning("Update not successful, no data received from %s", - self._feed) + _LOGGER.warning( + "Update not successful, no data received from %s", self._feed) # Remove all entities. - self._update_or_remove_entities([]) + self._remove_entities(self._managed_external_ids.copy()) - def _update_or_remove_entities(self, feed_entries): - """Update existing entries and remove obsolete entities.""" - _LOGGER.debug("Entries for updating: %s", feed_entries) - remove_entry = None - # Remove obsolete entities for events that have disappeared - managed_entities = self._managed_entities.copy() - for entity in managed_entities: - # Remove entry from previous iteration - if applicable. - if remove_entry: - feed_entries.remove(remove_entry) - remove_entry = None - for entry in feed_entries: - if entity.external_id == entry.external_id: - # Existing entity - update details. - _LOGGER.debug("Existing entity found %s", entity) - remove_entry = entry - entity.schedule_update_ha_state(True) - break - else: - # Remove obsolete entity. - _LOGGER.debug("Entity not current anymore %s", entity) - self._managed_entities.remove(entity) - self._hass.add_job(entity.async_remove()) - # Remove entry from very last iteration - if applicable. - if remove_entry: - feed_entries.remove(remove_entry) - # Return the remaining entries that new entities must be created for. - return feed_entries - - def _generate_new_entities(self, entries): + def _generate_new_entities(self, external_ids): """Generate new entities for events.""" new_entities = [] - for entry in entries: - new_entity = GeoJsonLocationEvent(self, entry) - _LOGGER.debug("New entity added %s", new_entity) + for external_id in external_ids: + new_entity = GeoJsonLocationEvent(self, external_id) + _LOGGER.debug("New entity added %s", external_id) new_entities.append(new_entity) - # Add new entities to HA and keep track of them in this manager. + self._managed_external_ids.add(external_id) + # Add new entities to HA. self._add_entities(new_entities, True) - self._managed_entities.extend(new_entities) - def get_feed_entry(self, external_id): - """Return a feed entry identified by external id.""" - return next((entry for entry in self._feed_entries - if entry.external_id == external_id), None) + def _update_entities(self, external_ids): + """Update entities.""" + for external_id in external_ids: + _LOGGER.debug("Existing entity found %s", external_id) + dispatcher_send( + self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + + def _remove_entities(self, external_ids): + """Remove entities.""" + for external_id in external_ids: + _LOGGER.debug("Entity not current anymore %s", external_id) + self._managed_external_ids.remove(external_id) + dispatcher_send( + self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) class GeoJsonLocationEvent(GeoLocationEvent): """This represents an external event with GeoJSON data.""" - def __init__(self, feed_manager, feed_entry): + def __init__(self, feed_manager, external_id): """Initialize entity with data from feed entry.""" self._feed_manager = feed_manager - self._update_from_feed(feed_entry) + self._external_id = external_id + self._name = None + self._distance = None + self._latitude = None + self._longitude = None + self._remove_signal_delete = None + self._remove_signal_update = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self._remove_signal_delete = async_dispatcher_connect( + self.hass, SIGNAL_DELETE_ENTITY.format(self._external_id), + self._delete_callback) + self._remove_signal_update = async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ENTITY.format(self._external_id), + self._update_callback) + + @callback + def _delete_callback(self): + """Remove this entity.""" + self._remove_signal_delete() + self._remove_signal_update() + self.hass.async_create_task(self.async_remove()) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) @property def should_poll(self): @@ -150,7 +183,8 @@ class GeoJsonLocationEvent(GeoLocationEvent): async def async_update(self): """Update this entity from the data held in the feed manager.""" - feed_entry = self._feed_manager.get_feed_entry(self.external_id) + _LOGGER.debug("Updating %s", self._external_id) + feed_entry = self._feed_manager.feed_entries.get(self._external_id) if feed_entry: self._update_from_feed(feed_entry) @@ -160,7 +194,11 @@ class GeoJsonLocationEvent(GeoLocationEvent): self._distance = feed_entry.distance_to_home self._latitude = feed_entry.coordinates[0] self._longitude = feed_entry.coordinates[1] - self.external_id = feed_entry.external_id + + @property + def source(self) -> str: + """Return source value of this external event.""" + return SOURCE @property def name(self) -> Optional[str]: @@ -191,6 +229,6 @@ class GeoJsonLocationEvent(GeoLocationEvent): def device_state_attributes(self): """Return the device state attributes.""" attributes = {} - if self.external_id: - attributes[ATTR_EXTERNAL_ID] = self.external_id + if self._external_id: + attributes[ATTR_EXTERNAL_ID] = self._external_id return attributes diff --git a/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py b/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py new file mode 100644 index 00000000000..79e0445f494 --- /dev/null +++ b/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py @@ -0,0 +1,288 @@ +""" +NSW Rural Fire Service Feed platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/geo_location/nsw_rural_fire_service_feed/ +""" +from datetime import timedelta +import logging +from typing import Optional + +import voluptuous as vol + +from homeassistant.components.geo_location import ( + PLATFORM_SCHEMA, GeoLocationEvent) +from homeassistant.const import ( + ATTR_ATTRIBUTION, ATTR_LOCATION, CONF_RADIUS, CONF_SCAN_INTERVAL, + EVENT_HOMEASSISTANT_START) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, dispatcher_send) +from homeassistant.helpers.event import track_time_interval + +REQUIREMENTS = ['geojson_client==0.1'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_CATEGORY = 'category' +ATTR_COUNCIL_AREA = 'council_area' +ATTR_EXTERNAL_ID = 'external_id' +ATTR_FIRE = 'fire' +ATTR_PUBLICATION_DATE = 'publication_date' +ATTR_RESPONSIBLE_AGENCY = 'responsible_agency' +ATTR_SIZE = 'size' +ATTR_STATUS = 'status' +ATTR_TYPE = 'type' + +CONF_CATEGORIES = 'categories' + +DEFAULT_RADIUS_IN_KM = 20.0 +DEFAULT_UNIT_OF_MEASUREMENT = 'km' + +SCAN_INTERVAL = timedelta(minutes=5) + +SIGNAL_DELETE_ENTITY = 'nsw_rural_fire_service_feed_delete_{}' +SIGNAL_UPDATE_ENTITY = 'nsw_rural_fire_service_feed_update_{}' + +SOURCE = 'nsw_rural_fire_service_feed' + +VALID_CATEGORIES = [ + 'Advice', + 'Emergency Warning', + 'Not Applicable', + 'Watch and Act', +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_CATEGORIES, default=[]): + vol.All(cv.ensure_list, [vol.In(VALID_CATEGORIES)]), + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the GeoJSON Events platform.""" + scan_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + radius_in_km = config[CONF_RADIUS] + categories = config.get(CONF_CATEGORIES) + # Initialize the entity manager. + feed = NswRuralFireServiceFeedManager( + hass, add_entities, scan_interval, radius_in_km, categories) + + def start_feed_manager(event): + """Start feed manager.""" + feed.startup() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) + + +class NswRuralFireServiceFeedManager: + """Feed Manager for NSW Rural Fire Service GeoJSON feed.""" + + def __init__(self, hass, add_entities, scan_interval, radius_in_km, + categories): + """Initialize the GeoJSON Feed Manager.""" + from geojson_client.nsw_rural_fire_service_feed \ + import NswRuralFireServiceFeed + + self._hass = hass + self._feed = NswRuralFireServiceFeed( + (hass.config.latitude, hass.config.longitude), + filter_radius=radius_in_km, filter_categories=categories) + self._add_entities = add_entities + self._scan_interval = scan_interval + self.feed_entries = {} + self._managed_external_ids = set() + + def startup(self): + """Start up this manager.""" + self._update() + self._init_regular_updates() + + def _init_regular_updates(self): + """Schedule regular updates at the specified interval.""" + track_time_interval( + self._hass, lambda now: self._update(), self._scan_interval) + + def _update(self): + """Update the feed and then update connected entities.""" + import geojson_client + + status, feed_entries = self._feed.update() + if status == geojson_client.UPDATE_OK: + _LOGGER.debug("Data retrieved %s", feed_entries) + # Keep a copy of all feed entries for future lookups by entities. + self.feed_entries = {entry.external_id: entry + for entry in feed_entries} + # For entity management the external ids from the feed are used. + feed_external_ids = set(self.feed_entries) + remove_external_ids = self._managed_external_ids.difference( + feed_external_ids) + self._remove_entities(remove_external_ids) + update_external_ids = self._managed_external_ids.intersection( + feed_external_ids) + self._update_entities(update_external_ids) + create_external_ids = feed_external_ids.difference( + self._managed_external_ids) + self._generate_new_entities(create_external_ids) + elif status == geojson_client.UPDATE_OK_NO_DATA: + _LOGGER.debug( + "Update successful, but no data received from %s", self._feed) + else: + _LOGGER.warning( + "Update not successful, no data received from %s", self._feed) + # Remove all entities. + self._remove_entities(self._managed_external_ids.copy()) + + def _generate_new_entities(self, external_ids): + """Generate new entities for events.""" + new_entities = [] + for external_id in external_ids: + new_entity = NswRuralFireServiceLocationEvent(self, external_id) + _LOGGER.debug("New entity added %s", external_id) + new_entities.append(new_entity) + self._managed_external_ids.add(external_id) + # Add new entities to HA. + self._add_entities(new_entities, True) + + def _update_entities(self, external_ids): + """Update entities.""" + for external_id in external_ids: + _LOGGER.debug("Existing entity found %s", external_id) + dispatcher_send( + self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + + def _remove_entities(self, external_ids): + """Remove entities.""" + for external_id in external_ids: + _LOGGER.debug("Entity not current anymore %s", external_id) + self._managed_external_ids.remove(external_id) + dispatcher_send( + self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + + +class NswRuralFireServiceLocationEvent(GeoLocationEvent): + """This represents an external event with GeoJSON data.""" + + def __init__(self, feed_manager, external_id): + """Initialize entity with data from feed entry.""" + self._feed_manager = feed_manager + self._external_id = external_id + self._name = None + self._distance = None + self._latitude = None + self._longitude = None + self._attribution = None + self._category = None + self._publication_date = None + self._location = None + self._council_area = None + self._status = None + self._type = None + self._fire = None + self._size = None + self._responsible_agency = None + self._remove_signal_delete = None + self._remove_signal_update = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self._remove_signal_delete = async_dispatcher_connect( + self.hass, SIGNAL_DELETE_ENTITY.format(self._external_id), + self._delete_callback) + self._remove_signal_update = async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ENTITY.format(self._external_id), + self._update_callback) + + @callback + def _delete_callback(self): + """Remove this entity.""" + self._remove_signal_delete() + self._remove_signal_update() + self.hass.async_create_task(self.async_remove()) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @property + def should_poll(self): + """No polling needed for GeoJSON location events.""" + return False + + async def async_update(self): + """Update this entity from the data held in the feed manager.""" + _LOGGER.debug("Updating %s", self._external_id) + feed_entry = self._feed_manager.feed_entries.get(self._external_id) + if feed_entry: + self._update_from_feed(feed_entry) + + def _update_from_feed(self, feed_entry): + """Update the internal state from the provided feed entry.""" + self._name = feed_entry.title + self._distance = feed_entry.distance_to_home + self._latitude = feed_entry.coordinates[0] + self._longitude = feed_entry.coordinates[1] + self._attribution = feed_entry.attribution + self._category = feed_entry.category + self._publication_date = feed_entry.publication_date + self._location = feed_entry.location + self._council_area = feed_entry.council_area + self._status = feed_entry.status + self._type = feed_entry.type + self._fire = feed_entry.fire + self._size = feed_entry.size + self._responsible_agency = feed_entry.responsible_agency + + @property + def source(self) -> str: + """Return source value of this external event.""" + return SOURCE + + @property + def name(self) -> Optional[str]: + """Return the name of the entity.""" + return self._name + + @property + def distance(self) -> Optional[float]: + """Return distance value of this external event.""" + return self._distance + + @property + def latitude(self) -> Optional[float]: + """Return latitude value of this external event.""" + return self._latitude + + @property + def longitude(self) -> Optional[float]: + """Return longitude value of this external event.""" + return self._longitude + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return DEFAULT_UNIT_OF_MEASUREMENT + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + for key, value in ( + (ATTR_EXTERNAL_ID, self._external_id), + (ATTR_CATEGORY, self._category), + (ATTR_LOCATION, self._location), + (ATTR_ATTRIBUTION, self._attribution), + (ATTR_PUBLICATION_DATE, self._publication_date), + (ATTR_COUNCIL_AREA, self._council_area), + (ATTR_STATUS, self._status), + (ATTR_TYPE, self._type), + (ATTR_FIRE, self._fire), + (ATTR_SIZE, self._size), + (ATTR_RESPONSIBLE_AGENCY, self._responsible_agency), + ): + if value or isinstance(value, bool): + attributes[key] = value + return attributes diff --git a/homeassistant/components/goalfeed.py b/homeassistant/components/goalfeed.py index f360d4ffba9..1a571960bc7 100644 --- a/homeassistant/components/goalfeed.py +++ b/homeassistant/components/goalfeed.py @@ -12,7 +12,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -REQUIREMENTS = ['pysher==0.2.0'] +REQUIREMENTS = ['pysher==1.0.4'] DOMAIN = 'goalfeed' diff --git a/homeassistant/components/graphite.py b/homeassistant/components/graphite.py index 2b768bc3786..26cd80d8da2 100644 --- a/homeassistant/components/graphite.py +++ b/homeassistant/components/graphite.py @@ -144,8 +144,7 @@ class GraphiteFeeder(threading.Thread): try: self._report_attributes( event.data['entity_id'], event.data['new_state']) - # pylint: disable=broad-except - except Exception: + except Exception: # pylint: disable=broad-except # Catch this so we can avoid the thread dying and # make it visible. _LOGGER.exception("Failed to process STATE_CHANGED event") diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 39fd7567c98..4dd3571e69c 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -30,6 +30,7 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' CONF_ENTITIES = 'entities' CONF_VIEW = 'view' CONF_CONTROL = 'control' +CONF_ALL = 'all' ATTR_ADD_ENTITIES = 'add_entities' ATTR_AUTO = 'auto' @@ -39,6 +40,7 @@ ATTR_OBJECT_ID = 'object_id' ATTR_ORDER = 'order' ATTR_VIEW = 'view' ATTR_VISIBLE = 'visible' +ATTR_ALL = 'all' SERVICE_SET_VISIBILITY = 'set_visibility' SERVICE_SET = 'set' @@ -60,6 +62,7 @@ SET_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ICON): cv.string, vol.Optional(ATTR_CONTROL): CONTROL_TYPES, vol.Optional(ATTR_VISIBLE): cv.boolean, + vol.Optional(ATTR_ALL): cv.boolean, vol.Exclusive(ATTR_ENTITIES, 'entities'): cv.entity_ids, vol.Exclusive(ATTR_ADD_ENTITIES, 'entities'): cv.entity_ids, }) @@ -85,6 +88,7 @@ GROUP_SCHEMA = vol.Schema({ CONF_NAME: cv.string, CONF_ICON: cv.icon, CONF_CONTROL: CONTROL_TYPES, + CONF_ALL: cv.boolean, }) CONFIG_SCHEMA = vol.Schema({ @@ -223,6 +227,7 @@ async def async_setup(hass, config): object_id=object_id, entity_ids=entity_ids, user_defined=False, + mode=service.data.get(ATTR_ALL), **extra_arg ) return @@ -265,6 +270,10 @@ async def async_setup(hass, config): group.view = service.data[ATTR_VIEW] need_update = True + if ATTR_ALL in service.data: + group.mode = all if service.data[ATTR_ALL] else any + need_update = True + if need_update: await group.async_update_ha_state() @@ -310,19 +319,21 @@ async def _async_process_config(hass, config, component): icon = conf.get(CONF_ICON) view = conf.get(CONF_VIEW) control = conf.get(CONF_CONTROL) + mode = conf.get(CONF_ALL) # Don't create tasks and await them all. The order is important as # groups get a number based on creation order. await Group.async_create_group( hass, name, entity_ids, icon=icon, view=view, - control=control, object_id=object_id) + control=control, object_id=object_id, mode=mode) class Group(Entity): """Track a group of entity ids.""" def __init__(self, hass, name, order=None, visible=True, icon=None, - view=False, control=None, user_defined=True, entity_ids=None): + view=False, control=None, user_defined=True, entity_ids=None, + mode=None): """Initialize a group. This Object has factory function for creation. @@ -341,6 +352,9 @@ class Group(Entity): self.visible = visible self.control = control self.user_defined = user_defined + self.mode = any + if mode: + self.mode = all self._order = order self._assumed_state = False self._async_unsub_state_changed = None @@ -348,18 +362,19 @@ class Group(Entity): @staticmethod def create_group(hass, name, entity_ids=None, user_defined=True, visible=True, icon=None, view=False, control=None, - object_id=None): + object_id=None, mode=None): """Initialize a group.""" return run_coroutine_threadsafe( Group.async_create_group( hass, name, entity_ids, user_defined, visible, icon, view, - control, object_id), + control, object_id, mode), hass.loop).result() @staticmethod async def async_create_group(hass, name, entity_ids=None, user_defined=True, visible=True, icon=None, - view=False, control=None, object_id=None): + view=False, control=None, object_id=None, + mode=None): """Initialize a group. This method must be run in the event loop. @@ -368,7 +383,7 @@ class Group(Entity): hass, name, order=len(hass.states.async_entity_ids(DOMAIN)), visible=visible, icon=icon, view=view, control=control, - user_defined=user_defined, entity_ids=entity_ids + user_defined=user_defined, entity_ids=entity_ids, mode=mode ) group.entity_id = async_generate_entity_id( @@ -557,13 +572,16 @@ class Group(Entity): if gr_on is None: return + # pylint: disable=too-many-boolean-expressions if tr_state is None or ((gr_state == gr_on and tr_state.state == gr_off) or + (gr_state == gr_off and + tr_state.state == gr_on) or tr_state.state not in (gr_on, gr_off)): if states is None: states = self._tracking_states - if any(state.state == gr_on for state in states): + if self.mode(state.state == gr_on for state in states): self._state = gr_on else: self._state = gr_off @@ -576,7 +594,7 @@ class Group(Entity): if states is None: states = self._tracking_states - self._assumed_state = any( + self._assumed_state = self.mode( state.attributes.get(ATTR_ASSUMED_STATE) for state in states) diff --git a/homeassistant/components/group/services.yaml b/homeassistant/components/group/services.yaml index f51f8b909d4..68c2f04f064 100644 --- a/homeassistant/components/group/services.yaml +++ b/homeassistant/components/group/services.yaml @@ -40,6 +40,9 @@ set: add_entities: description: List of members they will change on group listening. example: domain.entity_id1, domain.entity_id2 + all: + description: Enable this option if the group should only turn on when all entities are on. + example: True remove: description: Remove a user group. diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index 44b9e392157..8e77c6bf50b 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -1,55 +1,54 @@ """ The Habitica API component. -For more details about this platform, please refer to the documentation at +For more details about this component, please refer to the documentation at https://home-assistant.io/components/habitica/ """ - -import logging from collections import namedtuple +import logging import voluptuous as vol -from homeassistant.const import \ - CONF_NAME, CONF_URL, CONF_SENSORS, CONF_PATH, CONF_API_KEY + +from homeassistant.const import ( + CONF_API_KEY, CONF_NAME, CONF_PATH, CONF_SENSORS, CONF_URL) +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers import \ - config_validation as cv, discovery REQUIREMENTS = ['habitipy==0.2.0'] -_LOGGER = logging.getLogger(__name__) -DOMAIN = "habitica" -CONF_API_USER = "api_user" +_LOGGER = logging.getLogger(__name__) + +CONF_API_USER = 'api_user' + +DEFAULT_URL = 'https://habitica.com' +DOMAIN = 'habitica' ST = SensorType = namedtuple('SensorType', [ - "name", "icon", "unit", "path" + 'name', 'icon', 'unit', 'path' ]) SENSORS_TYPES = { - 'name': ST('Name', None, '', ["profile", "name"]), - 'hp': ST('HP', 'mdi:heart', 'HP', ["stats", "hp"]), - 'maxHealth': ST('max HP', 'mdi:heart', 'HP', ["stats", "maxHealth"]), - 'mp': ST('Mana', 'mdi:auto-fix', 'MP', ["stats", "mp"]), - 'maxMP': ST('max Mana', 'mdi:auto-fix', 'MP', ["stats", "maxMP"]), - 'exp': ST('EXP', 'mdi:star', 'EXP', ["stats", "exp"]), + 'name': ST('Name', None, '', ['profile', 'name']), + 'hp': ST('HP', 'mdi:heart', 'HP', ['stats', 'hp']), + 'maxHealth': ST('max HP', 'mdi:heart', 'HP', ['stats', 'maxHealth']), + 'mp': ST('Mana', 'mdi:auto-fix', 'MP', ['stats', 'mp']), + 'maxMP': ST('max Mana', 'mdi:auto-fix', 'MP', ['stats', 'maxMP']), + 'exp': ST('EXP', 'mdi:star', 'EXP', ['stats', 'exp']), 'toNextLevel': ST( - 'Next Lvl', 'mdi:star', 'EXP', ["stats", "toNextLevel"]), + 'Next Lvl', 'mdi:star', 'EXP', ['stats', 'toNextLevel']), 'lvl': ST( - 'Lvl', 'mdi:arrow-up-bold-circle-outline', 'Lvl', ["stats", "lvl"]), - 'gp': ST('Gold', 'mdi:coin', 'Gold', ["stats", "gp"]), - 'class': ST('Class', 'mdi:sword', '', ["stats", "class"]) + 'Lvl', 'mdi:arrow-up-bold-circle-outline', 'Lvl', ['stats', 'lvl']), + 'gp': ST('Gold', 'mdi:coin', 'Gold', ['stats', 'gp']), + 'class': ST('Class', 'mdi:sword', '', ['stats', 'class']) } INSTANCE_SCHEMA = vol.Schema({ - vol.Optional(CONF_URL, default='https://habitica.com'): cv.url, + vol.Optional(CONF_URL, default=DEFAULT_URL): cv.url, vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_API_USER): cv.string, vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_SENSORS, default=list(SENSORS_TYPES)): - vol.All( - cv.ensure_list, - vol.Unique(), - [vol.In(list(SENSORS_TYPES))]) + vol.All(cv.ensure_list, vol.Unique(), [vol.In(list(SENSORS_TYPES))]), }) has_unique_values = vol.Schema(vol.Unique()) # pylint: disable=invalid-name @@ -57,7 +56,7 @@ has_unique_values = vol.Schema(vol.Unique()) # pylint: disable=invalid-name def has_all_unique_users(value): - """Validate that all `api_user`s are unique.""" + """Validate that all API users are unique.""" api_users = [user[CONF_API_USER] for user in value] has_unique_values(api_users) return value @@ -75,9 +74,7 @@ def has_all_unique_users_names(value): INSTANCE_LIST_SCHEMA = vol.All( - cv.ensure_list, - has_all_unique_users, - has_all_unique_users_names, + cv.ensure_list, has_all_unique_users, has_all_unique_users_names, [INSTANCE_SCHEMA]) CONFIG_SCHEMA = vol.Schema({ @@ -87,23 +84,24 @@ CONFIG_SCHEMA = vol.Schema({ SERVICE_API_CALL = 'api_call' ATTR_NAME = CONF_NAME ATTR_PATH = CONF_PATH -ATTR_ARGS = "args" -EVENT_API_CALL_SUCCESS = "{0}_{1}_{2}".format( - DOMAIN, SERVICE_API_CALL, "success") +ATTR_ARGS = 'args' +EVENT_API_CALL_SUCCESS = '{0}_{1}_{2}'.format( + DOMAIN, SERVICE_API_CALL, 'success') SERVICE_API_CALL_SCHEMA = vol.Schema({ vol.Required(ATTR_NAME): str, vol.Required(ATTR_PATH): vol.All(cv.ensure_list, [str]), - vol.Optional(ATTR_ARGS): dict + vol.Optional(ATTR_ARGS): dict, }) async def async_setup(hass, config): - """Set up the habitica service.""" + """Set up the Habitica service.""" + from habitipy.aio import HabitipyAsync + conf = config[DOMAIN] data = hass.data[DOMAIN] = {} websession = async_get_clientsession(hass) - from habitipy.aio import HabitipyAsync class HAHabitipyAsync(HabitipyAsync): """Closure API class to hold session.""" @@ -116,7 +114,7 @@ async def async_setup(hass, config): username = instance[CONF_API_USER] password = instance[CONF_API_KEY] name = instance.get(CONF_NAME) - config_dict = {"url": url, "login": username, "password": password} + config_dict = {'url': url, 'login': username, 'password': password} api = HAHabitipyAsync(config_dict) user = await api.user.get() if name is None: @@ -125,34 +123,30 @@ async def async_setup(hass, config): if CONF_SENSORS in instance: hass.async_create_task( discovery.async_load_platform( - hass, "sensor", DOMAIN, - {"name": name, "sensors": instance[CONF_SENSORS]}, - config)) + hass, 'sensor', DOMAIN, + {'name': name, 'sensors': instance[CONF_SENSORS]}, config)) async def handle_api_call(call): name = call.data[ATTR_NAME] path = call.data[ATTR_PATH] api = hass.data[DOMAIN].get(name) if api is None: - _LOGGER.error( - "API_CALL: User '%s' not configured", name) + _LOGGER.error("API_CALL: User '%s' not configured", name) return try: for element in path: api = api[element] except KeyError: _LOGGER.error( - "API_CALL: Path %s is invalid" - " for api on '{%s}' element", path, element) + "API_CALL: Path %s is invalid for API on '{%s}' element", + path, element) return kwargs = call.data.get(ATTR_ARGS, {}) data = await api(**kwargs) - hass.bus.async_fire(EVENT_API_CALL_SUCCESS, { - "name": name, "path": path, "data": data - }) + hass.bus.async_fire( + EVENT_API_CALL_SUCCESS, {'name': name, 'path': path, 'data': data}) hass.services.async_register( - DOMAIN, SERVICE_API_CALL, - handle_api_call, + DOMAIN, SERVICE_API_CALL, handle_api_call, schema=SERVICE_API_CALL_SCHEMA) return True diff --git a/homeassistant/components/hangouts/.translations/hu.json b/homeassistant/components/hangouts/.translations/hu.json index 2631843c784..f6e46e25985 100644 --- a/homeassistant/components/hangouts/.translations/hu.json +++ b/homeassistant/components/hangouts/.translations/hu.json @@ -14,6 +14,7 @@ "data": { "2fa": "2FA Pin" }, + "description": "\u00dcres", "title": "K\u00e9tfaktoros Hiteles\u00edt\u00e9s" }, "user": { @@ -21,6 +22,7 @@ "email": "E-Mail C\u00edm", "password": "Jelsz\u00f3" }, + "description": "\u00dcres", "title": "Google Hangouts Bejelentkez\u00e9s" } }, diff --git a/homeassistant/components/hangouts/.translations/pt.json b/homeassistant/components/hangouts/.translations/pt.json index 64c960a121a..a16c60128c1 100644 --- a/homeassistant/components/hangouts/.translations/pt.json +++ b/homeassistant/components/hangouts/.translations/pt.json @@ -5,7 +5,7 @@ "unknown": "Ocorreu um erro desconhecido." }, "error": { - "invalid_2fa": "Autoriza\u00e7\u00e3o por 2 factores inv\u00e1lida, por favor, tente novamente.", + "invalid_2fa": "Autentica\u00e7\u00e3o por 2 fatores inv\u00e1lida, por favor, tente novamente.", "invalid_2fa_method": "M\u00e9todo 2FA inv\u00e1lido (verificar no telefone).", "invalid_login": "Login inv\u00e1lido, por favor, tente novamente." }, @@ -15,7 +15,7 @@ "2fa": "Pin 2FA" }, "description": "Vazio", - "title": "" + "title": "Autentica\u00e7\u00e3o de 2 Fatores" }, "user": { "data": { @@ -26,6 +26,6 @@ "title": "Login Google Hangouts" } }, - "title": "" + "title": "Google Hangouts" } } \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/ro.json b/homeassistant/components/hangouts/.translations/ro.json new file mode 100644 index 00000000000..d1c3ed767ce --- /dev/null +++ b/homeassistant/components/hangouts/.translations/ro.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts este deja configurat", + "unknown": "Sa produs o eroare necunoscut\u0103." + }, + "error": { + "invalid_2fa_method": "Metoda 2FA invalid\u0103 (Verifica\u021bi pe telefon).", + "invalid_login": "Conectare invalid\u0103, \u00eencerca\u021bi din nou." + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA Pin" + } + }, + "user": { + "data": { + "email": "Adresa de email", + "password": "Parol\u0103" + }, + "description": "Gol", + "title": "Conectare Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 5d7733584be..d4d8fe0216c 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -29,7 +29,7 @@ from .const import ( from .util import ( show_setup_message, validate_entity_config, validate_media_player_features) -REQUIREMENTS = ['HAP-python==2.2.2'] +REQUIREMENTS = ['HAP-python==2.3.0'] _LOGGER = logging.getLogger(__name__) @@ -78,7 +78,7 @@ async def async_setup(hass, config): homekit = HomeKit(hass, name, port, ip_address, entity_filter, entity_config) - await hass.async_add_job(homekit.setup) + await hass.async_add_executor_job(homekit.setup) if auto_start: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, homekit.start) @@ -173,6 +173,9 @@ def get_accessory(hass, driver, state, aid, config): elif state.domain in ('automation', 'input_boolean', 'remote', 'script'): a_type = 'Switch' + elif state.domain == 'water_heater': + a_type = 'WaterHeater' + if a_type is None: return None diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index adf5273b639..5baed0294b8 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -9,7 +9,8 @@ from pyhap.accessory_driver import AccessoryDriver from pyhap.const import CATEGORY_OTHER from homeassistant.const import ( - __version__, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL) + __version__, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, + ATTR_SERVICE) from homeassistant.core import callback as ha_callback from homeassistant.core import split_entity_id from homeassistant.helpers.event import ( @@ -17,9 +18,10 @@ from homeassistant.helpers.event import ( from homeassistant.util import dt as dt_util from .const import ( - BRIDGE_MODEL, BRIDGE_SERIAL_NUMBER, CHAR_BATTERY_LEVEL, - CHAR_CHARGING_STATE, CHAR_STATUS_LOW_BATTERY, DEBOUNCE_TIMEOUT, - MANUFACTURER, SERV_BATTERY_SERVICE) + ATTR_DISPLAY_NAME, ATTR_VALUE, BRIDGE_MODEL, BRIDGE_SERIAL_NUMBER, + CHAR_BATTERY_LEVEL, CHAR_CHARGING_STATE, CHAR_STATUS_LOW_BATTERY, + DEBOUNCE_TIMEOUT, EVENT_HOMEKIT_CHANGED, MANUFACTURER, + SERV_BATTERY_SERVICE) from .util import ( convert_to_float, show_setup_message, dismiss_setup_message) @@ -33,7 +35,7 @@ def debounce(func): """Handle call_later callback.""" debounce_params = self.debounce.pop(func.__name__, None) if debounce_params: - self.hass.async_add_job(func, self, *debounce_params[1:]) + self.hass.async_add_executor_job(func, self, *debounce_params[1:]) @wraps(func) def wrapper(self, *args): @@ -92,8 +94,15 @@ class HomeAccessory(Accessory): Run inside the HAP-python event loop. """ + self.hass.add_job(self.run_handler) + + async def run_handler(self): + """Handle accessory driver started event. + + Run inside the Home Assistant event loop. + """ state = self.hass.states.get(self.entity_id) - self.hass.add_job(self.update_state_callback, None, None, state) + self.hass.async_add_job(self.update_state_callback, None, None, state) async_track_state_change( self.hass, self.entity_id, self.update_state_callback) @@ -105,8 +114,8 @@ class HomeAccessory(Accessory): if new_state is None: return if self._support_battery_level: - self.hass.async_add_job(self.update_battery, new_state) - self.hass.async_add_job(self.update_state, new_state) + self.hass.async_add_executor_job(self.update_battery, new_state) + self.hass.async_add_executor_job(self.update_state, new_state) def update_battery(self, new_state): """Update battery service if available. @@ -137,6 +146,27 @@ class HomeAccessory(Accessory): """ raise NotImplementedError() + def call_service(self, domain, service, service_data, value=None): + """Fire event and call service for changes from HomeKit.""" + self.hass.add_job( + self.async_call_service, domain, service, service_data, value) + + async def async_call_service(self, domain, service, service_data, + value=None): + """Fire event and call service for changes from HomeKit. + + This method must be run in the event loop. + """ + event_data = { + ATTR_ENTITY_ID: self.entity_id, + ATTR_DISPLAY_NAME: self.display_name, + ATTR_SERVICE: service, + ATTR_VALUE: value + } + + self.hass.bus.async_fire(EVENT_HOMEKIT_CHANGED, event_data) + await self.hass.services.async_call(domain, service, service_data) + class HomeBridge(Bridge): """Adapter class for Bridge.""" diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 617dd3f4f22..d35d38c6455 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -5,6 +5,10 @@ DOMAIN = 'homekit' HOMEKIT_FILE = '.homekit.state' HOMEKIT_NOTIFY_ID = 4663548 +# #### Attributes #### +ATTR_DISPLAY_NAME = 'display_name' +ATTR_VALUE = 'value' + # #### Config #### CONF_AUTO_START = 'auto_start' CONF_ENTITY_CONFIG = 'entity_config' @@ -22,6 +26,9 @@ FEATURE_PLAY_PAUSE = 'play_pause' FEATURE_PLAY_STOP = 'play_stop' FEATURE_TOGGLE_MUTE = 'toggle_mute' +# #### HomeKit Component Event #### +EVENT_HOMEKIT_CHANGED = 'homekit_state_change' + # #### HomeKit Component Services #### SERVICE_HOMEKIT_START = 'start' @@ -139,3 +146,7 @@ DEVICE_CLASS_WINDOW = 'window' # #### Thresholds #### THRESHOLD_CO = 25 THRESHOLD_CO2 = 1000 + +# #### Default values #### +DEFAULT_MIN_TEMP_WATER_HEATER = 40 # °C +DEFAULT_MAX_TEMP_WATER_HEATER = 60 # °C diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index cf0620a4e30..840800f730b 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -31,7 +31,7 @@ class GarageDoorOpener(HomeAccessory): def __init__(self, *args): """Initialize a GarageDoorOpener accessory object.""" super().__init__(*args, category=CATEGORY_GARAGE_DOOR_OPENER) - self.flag_target_state = False + self._flag_state = False serv_garage_door = self.add_preload_service(SERV_GARAGE_DOOR_OPENER) self.char_current_state = serv_garage_door.configure_char( @@ -42,15 +42,15 @@ class GarageDoorOpener(HomeAccessory): def set_state(self, value): """Change garage state if call came from HomeKit.""" _LOGGER.debug('%s: Set state to %d', self.entity_id, value) - self.flag_target_state = True + self._flag_state = True params = {ATTR_ENTITY_ID: self.entity_id} if value == 0: self.char_current_state.set_value(3) - self.hass.services.call(DOMAIN, SERVICE_OPEN_COVER, params) + self.call_service(DOMAIN, SERVICE_OPEN_COVER, params) elif value == 1: self.char_current_state.set_value(2) - self.hass.services.call(DOMAIN, SERVICE_CLOSE_COVER, params) + self.call_service(DOMAIN, SERVICE_CLOSE_COVER, params) def update_state(self, new_state): """Update cover state after state changed.""" @@ -58,9 +58,9 @@ class GarageDoorOpener(HomeAccessory): if hass_state in (STATE_OPEN, STATE_CLOSED): current_state = 0 if hass_state == STATE_OPEN else 1 self.char_current_state.set_value(current_state) - if not self.flag_target_state: + if not self._flag_state: self.char_target_state.set_value(current_state) - self.flag_target_state = False + self._flag_state = False @TYPES.register('WindowCovering') @@ -73,7 +73,7 @@ class WindowCovering(HomeAccessory): def __init__(self, *args): """Initialize a WindowCovering accessory object.""" super().__init__(*args, category=CATEGORY_WINDOW_COVERING) - self.homekit_target = None + self._homekit_target = None serv_cover = self.add_preload_service(SERV_WINDOW_COVERING) self.char_current_position = serv_cover.configure_char( @@ -85,20 +85,20 @@ class WindowCovering(HomeAccessory): def move_cover(self, value): """Move cover to value if call came from HomeKit.""" _LOGGER.debug('%s: Set position to %d', self.entity_id, value) - self.homekit_target = value + self._homekit_target = value params = {ATTR_ENTITY_ID: self.entity_id, ATTR_POSITION: value} - self.hass.services.call(DOMAIN, SERVICE_SET_COVER_POSITION, params) + self.call_service(DOMAIN, SERVICE_SET_COVER_POSITION, params, value) def update_state(self, new_state): """Update cover position after state changed.""" current_position = new_state.attributes.get(ATTR_CURRENT_POSITION) if isinstance(current_position, int): self.char_current_position.set_value(current_position) - if self.homekit_target is None or \ - abs(current_position - self.homekit_target) < 6: + if self._homekit_target is None or \ + abs(current_position - self._homekit_target) < 6: self.char_target_position.set_value(current_position) - self.homekit_target = None + self._homekit_target = None @TYPES.register('WindowCoveringBasic') @@ -114,7 +114,7 @@ class WindowCoveringBasic(HomeAccessory): super().__init__(*args, category=CATEGORY_WINDOW_COVERING) features = self.hass.states.get(self.entity_id) \ .attributes.get(ATTR_SUPPORTED_FEATURES) - self.supports_stop = features & SUPPORT_STOP + self._supports_stop = features & SUPPORT_STOP serv_cover = self.add_preload_service(SERV_WINDOW_COVERING) self.char_current_position = serv_cover.configure_char( @@ -129,7 +129,7 @@ class WindowCoveringBasic(HomeAccessory): """Move cover to value if call came from HomeKit.""" _LOGGER.debug('%s: Set position to %d', self.entity_id, value) - if self.supports_stop: + if self._supports_stop: if value > 70: service, position = (SERVICE_OPEN_COVER, 100) elif value < 30: @@ -143,7 +143,7 @@ class WindowCoveringBasic(HomeAccessory): service, position = (SERVICE_CLOSE_COVER, 0) params = {ATTR_ENTITY_ID: self.entity_id} - self.hass.services.call(DOMAIN, service, params) + self.call_service(DOMAIN, service, params) # Snap the current/target position to the expected final position. self.char_current_position.set_value(position) diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index aa44b11fefb..5a860ed21c8 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -34,24 +34,27 @@ class Fan(HomeAccessory): CHAR_SWING_MODE: False} self._state = 0 - self.chars = [] + chars = [] features = self.hass.states.get(self.entity_id) \ .attributes.get(ATTR_SUPPORTED_FEATURES) if features & SUPPORT_DIRECTION: - self.chars.append(CHAR_ROTATION_DIRECTION) + chars.append(CHAR_ROTATION_DIRECTION) if features & SUPPORT_OSCILLATE: - self.chars.append(CHAR_SWING_MODE) + chars.append(CHAR_SWING_MODE) - serv_fan = self.add_preload_service(SERV_FANV2, self.chars) + serv_fan = self.add_preload_service(SERV_FANV2, chars) self.char_active = serv_fan.configure_char( CHAR_ACTIVE, value=0, setter_callback=self.set_state) - if CHAR_ROTATION_DIRECTION in self.chars: + self.char_direction = None + self.char_swing = None + + if CHAR_ROTATION_DIRECTION in chars: self.char_direction = serv_fan.configure_char( CHAR_ROTATION_DIRECTION, value=0, setter_callback=self.set_direction) - if CHAR_SWING_MODE in self.chars: + if CHAR_SWING_MODE in chars: self.char_swing = serv_fan.configure_char( CHAR_SWING_MODE, value=0, setter_callback=self.set_oscillating) @@ -61,7 +64,7 @@ class Fan(HomeAccessory): self._flag[CHAR_ACTIVE] = True service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF params = {ATTR_ENTITY_ID: self.entity_id} - self.hass.services.call(DOMAIN, service, params) + self.call_service(DOMAIN, service, params) def set_direction(self, value): """Set state if call came from HomeKit.""" @@ -69,7 +72,7 @@ class Fan(HomeAccessory): self._flag[CHAR_ROTATION_DIRECTION] = True direction = DIRECTION_REVERSE if value == 1 else DIRECTION_FORWARD params = {ATTR_ENTITY_ID: self.entity_id, ATTR_DIRECTION: direction} - self.hass.services.call(DOMAIN, SERVICE_SET_DIRECTION, params) + self.call_service(DOMAIN, SERVICE_SET_DIRECTION, params, direction) def set_oscillating(self, value): """Set state if call came from HomeKit.""" @@ -78,7 +81,7 @@ class Fan(HomeAccessory): oscillating = True if value == 1 else False params = {ATTR_ENTITY_ID: self.entity_id, ATTR_OSCILLATING: oscillating} - self.hass.services.call(DOMAIN, SERVICE_OSCILLATE, params) + self.call_service(DOMAIN, SERVICE_OSCILLATE, params, oscillating) def update_state(self, new_state): """Update fan after state change.""" @@ -92,7 +95,7 @@ class Fan(HomeAccessory): self._flag[CHAR_ACTIVE] = False # Handle Direction - if CHAR_ROTATION_DIRECTION in self.chars: + if self.char_direction is not None: direction = new_state.attributes.get(ATTR_DIRECTION) if not self._flag[CHAR_ROTATION_DIRECTION] and \ direction in (DIRECTION_FORWARD, DIRECTION_REVERSE): @@ -102,7 +105,7 @@ class Fan(HomeAccessory): self._flag[CHAR_ROTATION_DIRECTION] = False # Handle Oscillating - if CHAR_SWING_MODE in self.chars: + if self.char_swing is not None: oscillating = new_state.attributes.get(ATTR_OSCILLATING) if not self._flag[CHAR_SWING_MODE] and \ oscillating in (True, False): diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index da012799602..a9007ace35b 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -83,7 +83,7 @@ class Light(HomeAccessory): self._flag[CHAR_ON] = True params = {ATTR_ENTITY_ID: self.entity_id} service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF - self.hass.services.call(DOMAIN, service, params) + self.call_service(DOMAIN, service, params) @debounce def set_brightness(self, value): @@ -94,14 +94,16 @@ class Light(HomeAccessory): self.set_state(0) # Turn off light return params = {ATTR_ENTITY_ID: self.entity_id, ATTR_BRIGHTNESS_PCT: value} - self.hass.services.call(DOMAIN, SERVICE_TURN_ON, params) + self.call_service(DOMAIN, SERVICE_TURN_ON, params, + 'brightness at {}%'.format(value)) def set_color_temperature(self, value): """Set color temperature if call came from HomeKit.""" _LOGGER.debug('%s: Set color temp to %s', self.entity_id, value) self._flag[CHAR_COLOR_TEMPERATURE] = True params = {ATTR_ENTITY_ID: self.entity_id, ATTR_COLOR_TEMP: value} - self.hass.services.call(DOMAIN, SERVICE_TURN_ON, params) + self.call_service(DOMAIN, SERVICE_TURN_ON, params, + 'color temperature at {}'.format(value)) def set_saturation(self, value): """Set saturation if call came from HomeKit.""" @@ -126,7 +128,8 @@ class Light(HomeAccessory): self._flag.update({ CHAR_HUE: False, CHAR_SATURATION: False, RGB_COLOR: True}) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_HS_COLOR: color} - self.hass.services.call(DOMAIN, SERVICE_TURN_ON, params) + self.call_service(DOMAIN, SERVICE_TURN_ON, params, + 'set color at {}'.format(color)) def update_state(self, new_state): """Update light after state change.""" diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index 05ab6c6f822..fb211617ecf 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -33,7 +33,7 @@ class Lock(HomeAccessory): """Initialize a Lock accessory object.""" super().__init__(*args, category=CATEGORY_DOOR_LOCK) self._code = self.config.get(ATTR_CODE) - self.flag_target_state = False + self._flag_state = False serv_lock_mechanism = self.add_preload_service(SERV_LOCK) self.char_current_state = serv_lock_mechanism.configure_char( @@ -45,8 +45,8 @@ class Lock(HomeAccessory): def set_state(self, value): """Set lock state to value if call came from HomeKit.""" - _LOGGER.debug("%s: Set state to %d", self.entity_id, value) - self.flag_target_state = True + _LOGGER.debug('%s: Set state to %d', self.entity_id, value) + self._flag_state = True hass_value = HOMEKIT_TO_HASS.get(value) service = STATE_TO_SERVICE[hass_value] @@ -54,7 +54,7 @@ class Lock(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id} if self._code: params[ATTR_CODE] = self._code - self.hass.services.call(DOMAIN, service, params) + self.call_service(DOMAIN, service, params) def update_state(self, new_state): """Update lock after state changed.""" @@ -67,6 +67,6 @@ class Lock(HomeAccessory): # LockTargetState only supports locked and unlocked if hass_state in (STATE_LOCKED, STATE_UNLOCKED): - if not self.flag_target_state: + if not self._flag_state: self.char_target_state.set_value(current_lock_state) - self.flag_target_state = False + self._flag_state = False diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index ec41b9fd618..09088871fd2 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -76,7 +76,7 @@ class MediaPlayer(HomeAccessory): self._flag[FEATURE_ON_OFF] = True service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF params = {ATTR_ENTITY_ID: self.entity_id} - self.hass.services.call(DOMAIN, service, params) + self.call_service(DOMAIN, service, params) def set_play_pause(self, value): """Move switch state to value if call came from HomeKit.""" @@ -85,7 +85,7 @@ class MediaPlayer(HomeAccessory): self._flag[FEATURE_PLAY_PAUSE] = True service = SERVICE_MEDIA_PLAY if value else SERVICE_MEDIA_PAUSE params = {ATTR_ENTITY_ID: self.entity_id} - self.hass.services.call(DOMAIN, service, params) + self.call_service(DOMAIN, service, params) def set_play_stop(self, value): """Move switch state to value if call came from HomeKit.""" @@ -94,7 +94,7 @@ class MediaPlayer(HomeAccessory): self._flag[FEATURE_PLAY_STOP] = True service = SERVICE_MEDIA_PLAY if value else SERVICE_MEDIA_STOP params = {ATTR_ENTITY_ID: self.entity_id} - self.hass.services.call(DOMAIN, service, params) + self.call_service(DOMAIN, service, params) def set_toggle_mute(self, value): """Move switch state to value if call came from HomeKit.""" @@ -103,7 +103,7 @@ class MediaPlayer(HomeAccessory): self._flag[FEATURE_TOGGLE_MUTE] = True params = {ATTR_ENTITY_ID: self.entity_id, ATTR_MEDIA_VOLUME_MUTED: value} - self.hass.services.call(DOMAIN, SERVICE_VOLUME_MUTE, params) + self.call_service(DOMAIN, SERVICE_VOLUME_MUTE, params) def update_state(self, new_state): """Update switch state after state changed.""" diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index a7d36720cab..e210217df2f 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -39,7 +39,7 @@ class SecuritySystem(HomeAccessory): """Initialize a SecuritySystem accessory object.""" super().__init__(*args, category=CATEGORY_ALARM_SYSTEM) self._alarm_code = self.config.get(ATTR_CODE) - self.flag_target_state = False + self._flag_state = False serv_alarm = self.add_preload_service(SERV_SECURITY_SYSTEM) self.char_current_state = serv_alarm.configure_char( @@ -52,14 +52,14 @@ class SecuritySystem(HomeAccessory): """Move security state to value if call came from HomeKit.""" _LOGGER.debug('%s: Set security state to %d', self.entity_id, value) - self.flag_target_state = True + self._flag_state = True hass_value = HOMEKIT_TO_HASS[value] service = STATE_TO_SERVICE[hass_value] params = {ATTR_ENTITY_ID: self.entity_id} if self._alarm_code: params[ATTR_CODE] = self._alarm_code - self.hass.services.call(DOMAIN, service, params) + self.call_service(DOMAIN, service, params) def update_state(self, new_state): """Update security state after state changed.""" @@ -71,7 +71,7 @@ class SecuritySystem(HomeAccessory): self.entity_id, hass_state, current_security_state) # SecuritySystemTargetState does not support triggered - if not self.flag_target_state and \ + if not self._flag_state and \ hass_state != STATE_ALARM_TRIGGERED: self.char_target_state.set_value(current_security_state) - self.flag_target_state = False + self._flag_state = False diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index d2101b1e6f9..09da361ddb8 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -58,7 +58,6 @@ class TemperatureSensor(HomeAccessory): serv_temp = self.add_preload_service(SERV_TEMPERATURE_SENSOR) self.char_temp = serv_temp.configure_char( CHAR_CURRENT_TEMPERATURE, value=0, properties=PROP_CELSIUS) - self.unit = None def update_state(self, new_state): """Update temperature after state changed.""" diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 82a5d68d644..839abe5a580 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -1,7 +1,9 @@ """Class to hold all switch accessories.""" import logging -from pyhap.const import CATEGORY_OUTLET, CATEGORY_SWITCH +from pyhap.const import ( + CATEGORY_FAUCET, CATEGORY_OUTLET, CATEGORY_SHOWER_HEAD, + CATEGORY_SPRINKLER, CATEGORY_SWITCH) from homeassistant.components.switch import DOMAIN from homeassistant.const import ( @@ -17,10 +19,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -CATEGORY_SPRINKLER = 28 -CATEGORY_FAUCET = 29 -CATEGORY_SHOWER_HEAD = 30 - VALVE_TYPE = { TYPE_FAUCET: (CATEGORY_FAUCET, 3), TYPE_SHOWER: (CATEGORY_SHOWER_HEAD, 2), @@ -36,7 +34,7 @@ class Outlet(HomeAccessory): def __init__(self, *args): """Initialize an Outlet accessory object.""" super().__init__(*args, category=CATEGORY_OUTLET) - self.flag_target_state = False + self._flag_state = False serv_outlet = self.add_preload_service(SERV_OUTLET) self.char_on = serv_outlet.configure_char( @@ -48,19 +46,19 @@ class Outlet(HomeAccessory): """Move switch state to value if call came from HomeKit.""" _LOGGER.debug('%s: Set switch state to %s', self.entity_id, value) - self.flag_target_state = True + self._flag_state = True params = {ATTR_ENTITY_ID: self.entity_id} service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF - self.hass.services.call(DOMAIN, service, params) + self.call_service(DOMAIN, service, params) def update_state(self, new_state): """Update switch state after state changed.""" current_state = (new_state.state == STATE_ON) - if not self.flag_target_state: + if not self._flag_state: _LOGGER.debug('%s: Set current state to %s', self.entity_id, current_state) self.char_on.set_value(current_state) - self.flag_target_state = False + self._flag_state = False @TYPES.register('Switch') @@ -71,7 +69,7 @@ class Switch(HomeAccessory): """Initialize a Switch accessory object.""" super().__init__(*args, category=CATEGORY_SWITCH) self._domain = split_entity_id(self.entity_id)[0] - self.flag_target_state = False + self._flag_state = False serv_switch = self.add_preload_service(SERV_SWITCH) self.char_on = serv_switch.configure_char( @@ -81,19 +79,19 @@ class Switch(HomeAccessory): """Move switch state to value if call came from HomeKit.""" _LOGGER.debug('%s: Set switch state to %s', self.entity_id, value) - self.flag_target_state = True + self._flag_state = True params = {ATTR_ENTITY_ID: self.entity_id} service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF - self.hass.services.call(self._domain, service, params) + self.call_service(self._domain, service, params) def update_state(self, new_state): """Update switch state after state changed.""" current_state = (new_state.state == STATE_ON) - if not self.flag_target_state: + if not self._flag_state: _LOGGER.debug('%s: Set current state to %s', self.entity_id, current_state) self.char_on.set_value(current_state) - self.flag_target_state = False + self._flag_state = False @TYPES.register('Valve') @@ -103,7 +101,7 @@ class Valve(HomeAccessory): def __init__(self, *args): """Initialize a Valve accessory object.""" super().__init__(*args) - self.flag_target_state = False + self._flag_state = False valve_type = self.config[CONF_TYPE] self.category = VALVE_TYPE[valve_type][0] @@ -119,18 +117,18 @@ class Valve(HomeAccessory): """Move value state to value if call came from HomeKit.""" _LOGGER.debug('%s: Set switch state to %s', self.entity_id, value) - self.flag_target_state = True + self._flag_state = True self.char_in_use.set_value(value) params = {ATTR_ENTITY_ID: self.entity_id} service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF - self.hass.services.call(DOMAIN, service, params) + self.call_service(DOMAIN, service, params) def update_state(self, new_state): """Update switch state after state changed.""" current_state = (new_state.state == STATE_ON) - if not self.flag_target_state: + if not self._flag_state: _LOGGER.debug('%s: Set current state to %s', self.entity_id, current_state) self.char_active.set_value(current_state) self.char_in_use.set_value(current_state) - self.flag_target_state = False + self._flag_state = False diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 8517122f6a8..49da6db6125 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -6,13 +6,18 @@ from pyhap.const import CATEGORY_THERMOSTAT from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, ATTR_MAX_TEMP, ATTR_MIN_TEMP, ATTR_OPERATION_LIST, ATTR_OPERATION_MODE, - ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, - DOMAIN, SERVICE_SET_TEMPERATURE, SERVICE_SET_OPERATION_MODE, STATE_AUTO, - STATE_COOL, STATE_HEAT, SUPPORT_ON_OFF, SUPPORT_TARGET_TEMPERATURE_HIGH, - SUPPORT_TARGET_TEMPERATURE_LOW) + ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN as DOMAIN_CLIMATE, + SERVICE_SET_OPERATION_MODE as SERVICE_SET_OPERATION_MODE_THERMOSTAT, + SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_THERMOSTAT, + STATE_AUTO, STATE_COOL, STATE_HEAT, SUPPORT_ON_OFF, + SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) +from homeassistant.components.water_heater import ( + DOMAIN as DOMAIN_WATER_HEATER, + SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_WATER_HEATER) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_TURN_OFF, SERVICE_TURN_ON, + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, + SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) from . import TYPES @@ -21,7 +26,9 @@ from .const import ( CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_CURRENT_HEATING_COOLING, CHAR_CURRENT_TEMPERATURE, CHAR_TARGET_HEATING_COOLING, CHAR_HEATING_THRESHOLD_TEMPERATURE, CHAR_TARGET_TEMPERATURE, - CHAR_TEMP_DISPLAY_UNITS, PROP_MAX_VALUE, PROP_MIN_VALUE, SERV_THERMOSTAT) + CHAR_TEMP_DISPLAY_UNITS, + DEFAULT_MAX_TEMP_WATER_HEATER, DEFAULT_MIN_TEMP_WATER_HEATER, + PROP_MAX_VALUE, PROP_MIN_VALUE, SERV_THERMOSTAT) from .util import temperature_to_homekit, temperature_to_states _LOGGER = logging.getLogger(__name__) @@ -44,11 +51,11 @@ class Thermostat(HomeAccessory): """Initialize a Thermostat accessory object.""" super().__init__(*args, category=CATEGORY_THERMOSTAT) self._unit = self.hass.config.units.temperature_unit + self._flag_heat_cool = False + self._flag_temperature = False + self._flag_coolingthresh = False + self._flag_heatingthresh = False self.support_power_state = False - self.heat_cool_flag_target_state = False - self.temperature_flag_target_state = False - self.coolingthresh_flag_target_state = False - self.heatingthresh_flag_target_state = False min_temp, max_temp = self.get_temperature_range() # Add additional characteristics if auto mode is supported @@ -114,60 +121,70 @@ class Thermostat(HomeAccessory): return min_temp, max_temp def set_heat_cool(self, value): - """Move operation mode to value if call came from HomeKit.""" - if value in HC_HOMEKIT_TO_HASS: - _LOGGER.debug('%s: Set heat-cool to %d', self.entity_id, value) - self.heat_cool_flag_target_state = True - hass_value = HC_HOMEKIT_TO_HASS[value] - if self.support_power_state is True: - params = {ATTR_ENTITY_ID: self.entity_id} - if hass_value == STATE_OFF: - self.hass.services.call(DOMAIN, SERVICE_TURN_OFF, params) - return - self.hass.services.call(DOMAIN, SERVICE_TURN_ON, params) - params = {ATTR_ENTITY_ID: self.entity_id, - ATTR_OPERATION_MODE: hass_value} - self.hass.services.call(DOMAIN, SERVICE_SET_OPERATION_MODE, params) + """Change operation mode to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set heat-cool to %d', self.entity_id, value) + self._flag_heat_cool = True + hass_value = HC_HOMEKIT_TO_HASS[value] + if self.support_power_state is True: + params = {ATTR_ENTITY_ID: self.entity_id} + if hass_value == STATE_OFF: + self.call_service(DOMAIN_CLIMATE, SERVICE_TURN_OFF, params) + return + self.call_service(DOMAIN_CLIMATE, SERVICE_TURN_ON, params) + params = {ATTR_ENTITY_ID: self.entity_id, + ATTR_OPERATION_MODE: hass_value} + self.call_service( + DOMAIN_CLIMATE, SERVICE_SET_OPERATION_MODE_THERMOSTAT, + params, hass_value) @debounce def set_cooling_threshold(self, value): """Set cooling threshold temp to value if call came from HomeKit.""" _LOGGER.debug('%s: Set cooling threshold temperature to %.2f°C', self.entity_id, value) - self.coolingthresh_flag_target_state = True + self._flag_coolingthresh = True low = self.char_heating_thresh_temp.value + temperature = temperature_to_states(value, self._unit) params = { ATTR_ENTITY_ID: self.entity_id, - ATTR_TARGET_TEMP_HIGH: temperature_to_states(value, self._unit), + ATTR_TARGET_TEMP_HIGH: temperature, ATTR_TARGET_TEMP_LOW: temperature_to_states(low, self._unit)} - self.hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, params) + self.call_service( + DOMAIN_CLIMATE, SERVICE_SET_TEMPERATURE_THERMOSTAT, + params, 'cooling threshold {}{}'.format(temperature, self._unit)) @debounce def set_heating_threshold(self, value): """Set heating threshold temp to value if call came from HomeKit.""" _LOGGER.debug('%s: Set heating threshold temperature to %.2f°C', self.entity_id, value) - self.heatingthresh_flag_target_state = True + self._flag_heatingthresh = True high = self.char_cooling_thresh_temp.value + temperature = temperature_to_states(value, self._unit) params = { ATTR_ENTITY_ID: self.entity_id, ATTR_TARGET_TEMP_HIGH: temperature_to_states(high, self._unit), - ATTR_TARGET_TEMP_LOW: temperature_to_states(value, self._unit)} - self.hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, params) + ATTR_TARGET_TEMP_LOW: temperature} + self.call_service( + DOMAIN_CLIMATE, SERVICE_SET_TEMPERATURE_THERMOSTAT, + params, 'heating threshold {}{}'.format(temperature, self._unit)) @debounce def set_target_temperature(self, value): """Set target temperature to value if call came from HomeKit.""" _LOGGER.debug('%s: Set target temperature to %.2f°C', self.entity_id, value) - self.temperature_flag_target_state = True + self._flag_temperature = True + temperature = temperature_to_states(value, self._unit) params = { ATTR_ENTITY_ID: self.entity_id, - ATTR_TEMPERATURE: temperature_to_states(value, self._unit)} - self.hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, params) + ATTR_TEMPERATURE: temperature} + self.call_service( + DOMAIN_CLIMATE, SERVICE_SET_TEMPERATURE_THERMOSTAT, + params, '{}{}'.format(temperature, self._unit)) def update_state(self, new_state): - """Update security state after state changed.""" + """Update thermostat state after state changed.""" # Update current temperature current_temp = new_state.attributes.get(ATTR_CURRENT_TEMPERATURE) if isinstance(current_temp, (int, float)): @@ -178,9 +195,9 @@ class Thermostat(HomeAccessory): target_temp = new_state.attributes.get(ATTR_TEMPERATURE) if isinstance(target_temp, (int, float)): target_temp = temperature_to_homekit(target_temp, self._unit) - if not self.temperature_flag_target_state: + if not self._flag_temperature: self.char_target_temp.set_value(target_temp) - self.temperature_flag_target_state = False + self._flag_temperature = False # Update cooling threshold temperature if characteristic exists if self.char_cooling_thresh_temp: @@ -188,9 +205,9 @@ class Thermostat(HomeAccessory): if isinstance(cooling_thresh, (int, float)): cooling_thresh = temperature_to_homekit(cooling_thresh, self._unit) - if not self.coolingthresh_flag_target_state: + if not self._flag_coolingthresh: self.char_cooling_thresh_temp.set_value(cooling_thresh) - self.coolingthresh_flag_target_state = False + self._flag_coolingthresh = False # Update heating threshold temperature if characteristic exists if self.char_heating_thresh_temp: @@ -198,9 +215,9 @@ class Thermostat(HomeAccessory): if isinstance(heating_thresh, (int, float)): heating_thresh = temperature_to_homekit(heating_thresh, self._unit) - if not self.heatingthresh_flag_target_state: + if not self._flag_heatingthresh: self.char_heating_thresh_temp.set_value(heating_thresh) - self.heatingthresh_flag_target_state = False + self._flag_heatingthresh = False # Update display units if self._unit and self._unit in UNIT_HASS_TO_HOMEKIT: @@ -209,13 +226,12 @@ class Thermostat(HomeAccessory): # Update target operation mode operation_mode = new_state.attributes.get(ATTR_OPERATION_MODE) if self.support_power_state is True and new_state.state == STATE_OFF: - self.char_target_heat_cool.set_value( - HC_HASS_TO_HOMEKIT[STATE_OFF]) + self.char_target_heat_cool.set_value(0) # Off elif operation_mode and operation_mode in HC_HASS_TO_HOMEKIT: - if not self.heat_cool_flag_target_state: + if not self._flag_heat_cool: self.char_target_heat_cool.set_value( HC_HASS_TO_HOMEKIT[operation_mode]) - self.heat_cool_flag_target_state = False + self._flag_heat_cool = False # Set current operation mode based on temperatures and target mode if self.support_power_state is True and new_state.state == STATE_OFF: @@ -258,3 +274,92 @@ class Thermostat(HomeAccessory): self.char_current_heat_cool.set_value( HC_HASS_TO_HOMEKIT[current_operation_mode]) + + +@TYPES.register('WaterHeater') +class WaterHeater(HomeAccessory): + """Generate a WaterHeater accessory for a water_heater.""" + + def __init__(self, *args): + """Initialize a WaterHeater accessory object.""" + super().__init__(*args, category=CATEGORY_THERMOSTAT) + self._unit = self.hass.config.units.temperature_unit + self._flag_heat_cool = False + self._flag_temperature = False + min_temp, max_temp = self.get_temperature_range() + + serv_thermostat = self.add_preload_service(SERV_THERMOSTAT) + + self.char_current_heat_cool = serv_thermostat.configure_char( + CHAR_CURRENT_HEATING_COOLING, value=1) + self.char_target_heat_cool = serv_thermostat.configure_char( + CHAR_TARGET_HEATING_COOLING, value=1, + setter_callback=self.set_heat_cool) + + self.char_current_temp = serv_thermostat.configure_char( + CHAR_CURRENT_TEMPERATURE, value=50.0) + self.char_target_temp = serv_thermostat.configure_char( + CHAR_TARGET_TEMPERATURE, value=50.0, + properties={PROP_MIN_VALUE: min_temp, + PROP_MAX_VALUE: max_temp}, + setter_callback=self.set_target_temperature) + + self.char_display_units = serv_thermostat.configure_char( + CHAR_TEMP_DISPLAY_UNITS, value=0) + + def get_temperature_range(self): + """Return min and max temperature range.""" + max_temp = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_MAX_TEMP) + max_temp = temperature_to_homekit(max_temp, self._unit) if max_temp \ + else DEFAULT_MAX_TEMP_WATER_HEATER + + min_temp = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_MIN_TEMP) + min_temp = temperature_to_homekit(min_temp, self._unit) if min_temp \ + else DEFAULT_MIN_TEMP_WATER_HEATER + + return min_temp, max_temp + + def set_heat_cool(self, value): + """Change operation mode to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set heat-cool to %d', self.entity_id, value) + self._flag_heat_cool = True + hass_value = HC_HOMEKIT_TO_HASS[value] + if hass_value != STATE_HEAT: + self.char_target_heat_cool.set_value(1) # Heat + + @debounce + def set_target_temperature(self, value): + """Set target temperature to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set target temperature to %.2f°C', + self.entity_id, value) + self._flag_temperature = True + temperature = temperature_to_states(value, self._unit) + params = { + ATTR_ENTITY_ID: self.entity_id, + ATTR_TEMPERATURE: temperature} + self.call_service( + DOMAIN_WATER_HEATER, SERVICE_SET_TEMPERATURE_WATER_HEATER, + params, '{}{}'.format(temperature, self._unit)) + + def update_state(self, new_state): + """Update water_heater state after state change.""" + # Update current and target temperature + temperature = new_state.attributes.get(ATTR_TEMPERATURE) + if isinstance(temperature, (int, float)): + temperature = temperature_to_homekit(temperature, self._unit) + self.char_current_temp.set_value(temperature) + if not self._flag_temperature: + self.char_target_temp.set_value(temperature) + self._flag_temperature = False + + # Update display units + if self._unit and self._unit in UNIT_HASS_TO_HOMEKIT: + self.char_display_units.set_value(UNIT_HASS_TO_HOMEKIT[self._unit]) + + # Update target operation mode + operation_mode = new_state.attributes.get(ATTR_OPERATION_MODE) + if operation_mode and not self._flag_heat_cool: + self.char_target_heat_cool.set_value(1) # Heat + self._flag_heat_cool = False diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 4dd7396cf8d..43ae4df3b50 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -104,7 +104,7 @@ def validate_media_player_features(state, feature_list): error_list.append(feature) if error_list: - _LOGGER.error("%s does not support features: %s", + _LOGGER.error('%s does not support features: %s', state.entity_id, error_list) return False return True diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 5431dd4a61a..ebb4a2db9cb 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -65,8 +65,7 @@ def homekit_http_send(self, message_body=None, encode_chunked=False): def get_serial(accessory): """Obtain the serial number of a HomeKit device.""" - # pylint: disable=import-error - import homekit + import homekit # pylint: disable=import-error for service in accessory['services']: if homekit.ServicesTypes.get_short(service['type']) != \ 'accessory-information': @@ -85,8 +84,7 @@ class HKDevice(): def __init__(self, hass, host, port, model, hkid, config_num, config): """Initialise a generic HomeKit device.""" - # pylint: disable=import-error - import homekit + import homekit # pylint: disable=import-error _LOGGER.info("Setting up Homekit device %s", model) self.hass = hass @@ -132,8 +130,7 @@ class HKDevice(): def accessory_setup(self): """Handle setup of a HomeKit accessory.""" - # pylint: disable=import-error - import homekit + import homekit # pylint: disable=import-error try: data = self.get_json('/accessories') @@ -185,8 +182,7 @@ class HKDevice(): def device_config_callback(self, callback_data): """Handle initial pairing.""" - # pylint: disable=import-error - import homekit + import homekit # pylint: disable=import-error pairing_id = str(uuid.uuid4()) code = callback_data.get('code').strip() try: diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 927f86b590d..4343bcfbc08 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -18,7 +18,7 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyhomematic==0.1.50'] +REQUIREMENTS = ['pyhomematic==0.1.51'] _LOGGER = logging.getLogger(__name__) @@ -76,7 +76,7 @@ HM_DEVICE_TYPES = { 'IPSmoke', 'RFSiren', 'PresenceIP', 'IPAreaThermostat', 'IPWeatherSensor', 'RotaryHandleSensorIP', 'IPPassageSensor', 'IPKeySwitchPowermeter', 'IPThermostatWall230V', 'IPWeatherSensorPlus', - 'IPWeatherSensorBasic'], + 'IPWeatherSensorBasic', 'IPBrightnessSensor'], DISCOVER_CLIMATE: [ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall', @@ -97,17 +97,21 @@ HM_IGNORE_DISCOVERY_NODE = [ ] HM_IGNORE_DISCOVERY_NODE_EXCEPTIONS = { - 'ACTUAL_TEMPERATURE': ['IPAreaThermostat', 'IPWeatherSensor'], + 'ACTUAL_TEMPERATURE': [ + 'IPAreaThermostat', 'IPWeatherSensor', + 'IPWeatherSensorPlus', 'IPWeatherSensorBasic'], } HM_ATTRIBUTE_SUPPORT = { 'LOWBAT': ['battery', {0: 'High', 1: 'Low'}], 'LOW_BAT': ['battery', {0: 'High', 1: 'Low'}], 'ERROR': ['sabotage', {0: 'No', 1: 'Yes'}], + 'ERROR_SABOTAGE': ['sabotage', {0: 'No', 1: 'Yes'}], 'SABOTAGE': ['sabotage', {0: 'No', 1: 'Yes'}], 'RSSI_PEER': ['rssi', {}], 'RSSI_DEVICE': ['rssi', {}], 'VALVE_STATE': ['valve', {}], + 'LEVEL': ['level', {}], 'BATTERY_STATE': ['battery', {}], 'CONTROL_MODE': ['mode', { 0: 'Auto', @@ -775,8 +779,7 @@ class HMDevice(Entity): # Link events from pyhomematic self._subscribe_homematic_events() self._available = not self._hmdevice.UNREACH - # pylint: disable=broad-except - except Exception as err: + except Exception as err: # pylint: disable=broad-except self._connected = False _LOGGER.error("Exception while linking %s: %s", self._address, str(err)) @@ -795,11 +798,8 @@ class HMDevice(Entity): has_changed = True # Availability has changed - if attribute == 'UNREACH': - self._available = not bool(value) - has_changed = True - elif not self.available: - self._available = False + if self.available != (not self._hmdevice.UNREACH): + self._available = not self._hmdevice.UNREACH has_changed = True # If it has changed data point, update HASS @@ -809,7 +809,6 @@ class HMDevice(Entity): def _subscribe_homematic_events(self): """Subscribe all required events to handle job.""" channels_to_sub = set() - channels_to_sub.add(0) # Add channel 0 for UNREACH # Push data to channels_to_sub from hmdevice metadata for metadata in (self._hmdevice.SENSORNODE, self._hmdevice.BINARYNODE, diff --git a/homeassistant/components/homematicip_cloud/.translations/ro.json b/homeassistant/components/homematicip_cloud/.translations/ro.json new file mode 100644 index 00000000000..a5399e7e68c --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/ro.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Punctul de acces este deja configurat", + "unknown": "Sa produs o eroare necunoscut\u0103." + }, + "error": { + "invalid_pin": "Cod PIN invalid, \u00eencerca\u021bi din nou.", + "press_the_button": "V\u0103 rug\u0103m s\u0103 ap\u0103sa\u021bi butonul albastru." + }, + "step": { + "init": { + "data": { + "pin": "Cod PIN (op\u021bional)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte.py b/homeassistant/components/huawei_lte.py index ad134d8c60e..e8d3bc8b3a1 100644 --- a/homeassistant/components/huawei_lte.py +++ b/homeassistant/components/huawei_lte.py @@ -21,6 +21,9 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) +# dicttoxml (used by huawei-lte-api) has uselessly verbose INFO level. +logging.getLogger('dicttoxml').setLevel(logging.WARNING) + REQUIREMENTS = ['huawei-lte-api==1.0.16'] MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) diff --git a/homeassistant/components/hue/.translations/pt.json b/homeassistant/components/hue/.translations/pt.json index f7988d82d8c..d52540b0921 100644 --- a/homeassistant/components/hue/.translations/pt.json +++ b/homeassistant/components/hue/.translations/pt.json @@ -24,6 +24,6 @@ "title": "Link Hub" } }, - "title": "" + "title": "Philips Hue" } } \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/ro.json b/homeassistant/components/hue/.translations/ro.json index 69cee1198d3..a2ecf8964b6 100644 --- a/homeassistant/components/hue/.translations/ro.json +++ b/homeassistant/components/hue/.translations/ro.json @@ -2,6 +2,8 @@ "config": { "abort": { "all_configured": "Toate pun\u021bile Philips Hue sunt deja configurate", + "already_configured": "Gateway-ul este deja configurat", + "cannot_connect": "Nu se poate conecta la gateway.", "discover_timeout": "Imposibil de descoperit podurile Hue" }, "error": { diff --git a/homeassistant/components/ifttt/.translations/es.json b/homeassistant/components/ifttt/.translations/es.json new file mode 100644 index 00000000000..13240ccefb1 --- /dev/null +++ b/homeassistant/components/ifttt/.translations/es.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "description": "\u00bfEst\u00e1s seguro de que quieres configurar IFTTT?", + "title": "Configurar el applet de webhook IFTTT" + } + }, + "title": "IFTTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/ifttt/.translations/hu.json b/homeassistant/components/ifttt/.translations/hu.json index a131f848d45..6ecf654ff47 100644 --- a/homeassistant/components/ifttt/.translations/hu.json +++ b/homeassistant/components/ifttt/.translations/hu.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "not_internet_accessible": "A Home Assistant-nek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l az IFTTT \u00fczenetek fogad\u00e1s\u00e1hoz." + }, "step": { "user": { "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani az IFTTT-t?", diff --git a/homeassistant/components/ifttt/.translations/ko.json b/homeassistant/components/ifttt/.translations/ko.json index 57ad8037753..2f033e4f4ee 100644 --- a/homeassistant/components/ifttt/.translations/ko.json +++ b/homeassistant/components/ifttt/.translations/ko.json @@ -5,7 +5,7 @@ "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." }, "create_entry": { - "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\uae30 \uc704\ud574\uc11c\ub294 [IFTTT Webhook \uc560\ud50c\ub9bf]({applet_url}) \uc5d0\uc11c \"Make a web request\" \ub97c \uc0ac\uc6a9\ud574\uc57c \ud569\ub2c8\ub2e4. \n\n \ub2e4\uc74c\uc758 \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694.\n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\n Home Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url})\ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." + "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\uae30 \uc704\ud574\uc11c\ub294 [IFTTT Webhook \uc560\ud50c\ub9bf]({applet_url}) \uc5d0\uc11c \"Make a web request\" \ub97c \uc0ac\uc6a9\ud574\uc57c \ud569\ub2c8\ub2e4. \n\n \ub2e4\uc74c\uc758 \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694.\n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\n Home Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." }, "step": { "user": { diff --git a/homeassistant/components/ifttt/.translations/pl.json b/homeassistant/components/ifttt/.translations/pl.json index 3c3c2182503..7064364ebe6 100644 --- a/homeassistant/components/ifttt/.translations/pl.json +++ b/homeassistant/components/ifttt/.translations/pl.json @@ -5,12 +5,12 @@ "one_instance_allowed": "Wymagana jest tylko jedna instancja." }, "create_entry": { - "default": "Aby wys\u0142a\u0107 zdarzenia do Home Assistant'a, b\u0119dziesz musia\u0142 u\u017cy\u0107 akcji \"Make a web request\" z [IFTTT Webhook apletu]({applet_url}). \n\n Podaj nast\u0119puj\u0105ce informacje:\n\n - URL: `{webhook_url}`\n - Metoda: POST\n - Typ zawarto\u015bci: application/json\n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}) na temat konfiguracji automatyzacji by obs\u0142u\u017cy\u0107 przychodz\u0105ce dane." + "default": "Aby wys\u0142a\u0107 zdarzenia do Home Assistant'a, b\u0119dziesz musia\u0142 u\u017cy\u0107 akcji \"Make a web request\" z [IFTTT Webhook apletu]({applet_url}). \n\n Wprowad\u017a nast\u0119puj\u0105ce dane:\n\n - URL: `{webhook_url}`\n - Metoda: POST\n - Typ zawarto\u015bci: application/json\n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}) na temat konfiguracji automatyzacji by obs\u0142u\u017cy\u0107 przychodz\u0105ce dane." }, "step": { "user": { - "description": "Jeste\u015b pewny, \u017ce chcesz skonfigurowa\u0107 IFTTT?", - "title": "Konfigurowanie apletu Webhook IFTTT" + "description": "Czy chcesz skonfigurowa\u0107 IFTTT?", + "title": "Konfiguracja apletu Webhook IFTTT" } }, "title": "IFTTT" diff --git a/homeassistant/components/ifttt/.translations/pt.json b/homeassistant/components/ifttt/.translations/pt.json new file mode 100644 index 00000000000..08b7aee6a08 --- /dev/null +++ b/homeassistant/components/ifttt/.translations/pt.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "A sua inst\u00e2ncia Home Assistent precisa de ser acess\u00edvel a partir da internet para receber mensagens IFTTT.", + "one_instance_allowed": "Apenas uma \u00fanica inst\u00e2ncia \u00e9 necess\u00e1ria." + }, + "create_entry": { + "default": "Para enviar eventos para o Home Assistente, precisa de utilizar a a\u00e7\u00e3o \"Make a web request\" no [IFTTT Webhook applet]({applet_url}).\n\nPreencha com a seguinte informa\u00e7\u00e3o:\n\n- URL: `{webhook_url}`\n- Method: POST \n- Content Type: application/json \n\nConsulte [a documenta\u00e7\u00e3o]({docs_url}) sobre como configurar automa\u00e7\u00f5es para lidar com dados de entrada." + }, + "step": { + "user": { + "description": "Tem certeza de que deseja configurar o IFTTT?", + "title": "Configurar o IFTTT Webhook Applet" + } + }, + "title": "IFTTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/ifttt/.translations/ro.json b/homeassistant/components/ifttt/.translations/ro.json index 03c77426671..dd7ae5f72cb 100644 --- a/homeassistant/components/ifttt/.translations/ro.json +++ b/homeassistant/components/ifttt/.translations/ro.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "not_internet_accessible": "Instan\u021ba Home Assistant trebuie s\u0103 fie accesibil\u0103 de pe internet pentru a primi mesaje IFTTT.", + "one_instance_allowed": "Este necesar\u0103 o singur\u0103 instan\u021b\u0103." + }, "step": { "user": { "description": "Sigur dori\u021bi s\u0103 configura\u021bi IFTTT?" diff --git a/homeassistant/components/ifttt/.translations/tr.json b/homeassistant/components/ifttt/.translations/tr.json new file mode 100644 index 00000000000..80188b637f9 --- /dev/null +++ b/homeassistant/components/ifttt/.translations/tr.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "IFTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index 60748d6ff13..76f01ad0aca 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -14,6 +14,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant import config_entries +from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.util.network import is_local REQUIREMENTS = ['pyfttt==0.3'] @@ -29,7 +30,6 @@ ATTR_VALUE2 = 'value2' ATTR_VALUE3 = 'value3' CONF_KEY = 'key' -CONF_WEBHOOK_ID = 'webhook_id' DOMAIN = 'ifttt' @@ -91,13 +91,13 @@ async def handle_webhook(hass, webhook_id, request): async def async_setup_entry(hass, entry): """Configure based on config entry.""" hass.components.webhook.async_register( - entry.data['webhook_id'], handle_webhook) + entry.data[CONF_WEBHOOK_ID], handle_webhook) return True async def async_unload_entry(hass, entry): """Unload a config entry.""" - hass.components.webhook.async_unregister(entry.data['webhook_id']) + hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) return True diff --git a/homeassistant/components/image_processing/dlib_face_detect.py b/homeassistant/components/image_processing/dlib_face_detect.py index d3b4e14f4de..cb9ea5ff5f9 100644 --- a/homeassistant/components/image_processing/dlib_face_detect.py +++ b/homeassistant/components/image_processing/dlib_face_detect.py @@ -58,8 +58,7 @@ class DlibFaceDetectEntity(ImageProcessingFaceEntity): def process_image(self, image): """Process image.""" - # pylint: disable=import-error - import face_recognition + import face_recognition # pylint: disable=import-error fak_file = io.BytesIO(image) fak_file.name = 'snapshot.jpg' diff --git a/homeassistant/components/image_processing/opencv.py b/homeassistant/components/image_processing/opencv.py index 1e5d38b638e..062c18bb730 100644 --- a/homeassistant/components/image_processing/opencv.py +++ b/homeassistant/components/image_processing/opencv.py @@ -16,7 +16,7 @@ from homeassistant.components.image_processing import ( from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.15.1'] +REQUIREMENTS = ['numpy==1.15.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/insteon/__init__.py b/homeassistant/components/insteon/__init__.py index 924baeaa560..3980503a1ac 100644 --- a/homeassistant/components/insteon/__init__.py +++ b/homeassistant/components/insteon/__init__.py @@ -19,7 +19,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['insteonplm==0.14.2'] +REQUIREMENTS = ['insteonplm==0.15.0'] _LOGGER = logging.getLogger(__name__) @@ -465,6 +465,16 @@ class InsteonEntity(Entity): """Return the INSTEON group that the entity responds to.""" return self._insteon_device_state.group + @property + def unique_id(self) -> str: + """Return a unique ID.""" + if self._insteon_device_state.group == 0x01: + uid = self._insteon_device.id + else: + uid = '{:s}_{:d}'.format(self._insteon_device.id, + self._insteon_device_state.group) + return uid + @property def name(self): """Return the name of the node (used for Entity_ID).""" diff --git a/homeassistant/components/ios/.translations/pt.json b/homeassistant/components/ios/.translations/pt.json index 6752606d9f5..d38b9abb70b 100644 --- a/homeassistant/components/ios/.translations/pt.json +++ b/homeassistant/components/ios/.translations/pt.json @@ -6,9 +6,9 @@ "step": { "confirm": { "description": "Deseja configurar o componente iOS do Home Assistant?", - "title": "" + "title": "Home Assistant iOS" } }, - "title": "" + "title": "Home Assistant iOS" } } \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/ro.json b/homeassistant/components/ios/.translations/ro.json new file mode 100644 index 00000000000..5a83b5cd732 --- /dev/null +++ b/homeassistant/components/ios/.translations/ro.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Este necesar\u0103 numai o singur\u0103 configurare a aplica\u021biei Home Assistant iOS." + }, + "step": { + "confirm": { + "description": "Dori\u021bi s\u0103 configura\u021bi componenta Home Assistant iOS?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/keyboard.py b/homeassistant/components/keyboard.py index 58bb1fa5f42..16253ba271a 100644 --- a/homeassistant/components/keyboard.py +++ b/homeassistant/components/keyboard.py @@ -20,8 +20,7 @@ TAP_KEY_SCHEMA = vol.Schema({}) def setup(hass, config): """Listen for keyboard events.""" - # pylint: disable=import-error - import pykeyboard + import pykeyboard # pylint: disable=import-error keyboard = pykeyboard.PyKeyboard() keyboard.special_key_assignment() diff --git a/homeassistant/components/keyboard_remote.py b/homeassistant/components/keyboard_remote.py index 9a7cc7caecb..ffc92f1949a 100644 --- a/homeassistant/components/keyboard_remote.py +++ b/homeassistant/components/keyboard_remote.py @@ -4,7 +4,6 @@ Receive signals from a keyboard and use it as a remote control. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/keyboard_remote/ """ -# pylint: disable=import-error import threading import logging import os @@ -90,6 +89,7 @@ class KeyboardRemoteThread(threading.Thread): id_folder = '/dev/input/by-id/' if os.path.isdir(id_folder): + # pylint: disable=import-error from evdev import InputDevice, list_devices device_names = [InputDevice(file_name).name for file_name in list_devices()] @@ -104,6 +104,7 @@ class KeyboardRemoteThread(threading.Thread): def _get_keyboard_device(self): """Get the keyboard device.""" + # pylint: disable=import-error from evdev import InputDevice, list_devices if self.device_name: devices = [InputDevice(file_name) for file_name in list_devices()] @@ -121,6 +122,7 @@ class KeyboardRemoteThread(threading.Thread): def run(self): """Run the loop of the KeyboardRemote.""" + # pylint: disable=import-error from evdev import categorize, ecodes if self.dev is not None: diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index b15963fa6bd..6aef3ea4ec5 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -240,7 +240,7 @@ class KNXModule: async def telegram_received_cb(self, telegram): """Call invoked after a KNX telegram was received.""" - self.hass.bus.fire('knx_event', { + self.hass.bus.async_fire('knx_event', { 'address': str(telegram.group_address), 'data': telegram.payload.value }) diff --git a/homeassistant/components/konnected.py b/homeassistant/components/konnected.py index 388fa41f36f..21e2fbba4c7 100644 --- a/homeassistant/components/konnected.py +++ b/homeassistant/components/konnected.py @@ -4,9 +4,11 @@ Support for Konnected devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/konnected/ """ -import logging +import asyncio import hmac import json +import logging + import voluptuous as vol from aiohttp.hdrs import AUTHORIZATION @@ -16,17 +18,18 @@ from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA from homeassistant.components.discovery import SERVICE_KONNECTED from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( - HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_UNAUTHORIZED, - CONF_DEVICES, CONF_BINARY_SENSORS, CONF_SWITCHES, CONF_HOST, CONF_PORT, - CONF_ID, CONF_NAME, CONF_TYPE, CONF_PIN, CONF_ZONE, CONF_ACCESS_TOKEN, - ATTR_ENTITY_ID, ATTR_STATE, STATE_ON) -from homeassistant.helpers.dispatcher import async_dispatcher_send + EVENT_HOMEASSISTANT_START, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, + HTTP_UNAUTHORIZED, CONF_DEVICES, CONF_BINARY_SENSORS, CONF_SWITCHES, + CONF_HOST, CONF_PORT, CONF_ID, CONF_NAME, CONF_TYPE, CONF_PIN, CONF_ZONE, + CONF_ACCESS_TOKEN, ATTR_ENTITY_ID, ATTR_STATE, STATE_ON) +from homeassistant.helpers.dispatcher import ( + async_dispatcher_send, dispatcher_send) from homeassistant.helpers import discovery from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['konnected==0.1.2'] +REQUIREMENTS = ['konnected==0.1.4'] DOMAIN = 'konnected' @@ -36,6 +39,8 @@ CONF_MOMENTARY = 'momentary' CONF_PAUSE = 'pause' CONF_REPEAT = 'repeat' CONF_INVERSE = 'inverse' +CONF_BLINK = 'blink' +CONF_DISCOVERY = 'discovery' STATE_LOW = 'low' STATE_HIGH = 'high' @@ -49,7 +54,7 @@ _BINARY_SENSOR_SCHEMA = vol.All( vol.Exclusive(CONF_ZONE, 's_pin'): vol.Any(*ZONE_TO_PIN), vol.Required(CONF_TYPE): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_INVERSE): cv.boolean, + vol.Optional(CONF_INVERSE, default=False): cv.boolean, }), cv.has_at_least_one_key(CONF_PIN, CONF_ZONE) ) @@ -81,6 +86,10 @@ CONFIG_SCHEMA = vol.Schema( cv.ensure_list, [_BINARY_SENSOR_SCHEMA]), vol.Optional(CONF_SWITCHES): vol.All( cv.ensure_list, [_SWITCH_SCHEMA]), + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_BLINK, default=True): cv.boolean, + vol.Optional(CONF_DISCOVERY, default=True): cv.boolean, }], }), }, @@ -96,6 +105,8 @@ SIGNAL_SENSOR_UPDATE = 'konnected.{}.update' async def async_setup(hass, config): """Set up the Konnected platform.""" + import konnected + cfg = config.get(DOMAIN) if cfg is None: cfg = {} @@ -107,10 +118,8 @@ async def async_setup(hass, config): CONF_API_HOST: cfg.get(CONF_API_HOST) } - def device_discovered(service, info): - """Call when a Konnected device has been discovered.""" - host = info.get(CONF_HOST) - port = info.get(CONF_PORT) + def setup_device(host, port): + """Set up a Konnected device at `host` listening on `port`.""" discovered = DiscoveredDevice(hass, host, port) if discovered.is_configured: discovered.setup() @@ -119,6 +128,33 @@ async def async_setup(hass, config): " but not specified in configuration.yaml", discovered.device_id) + def device_discovered(service, info): + """Call when a Konnected device has been discovered.""" + host = info.get(CONF_HOST) + port = info.get(CONF_PORT) + setup_device(host, port) + + async def manual_discovery(event): + """Init devices on the network with manually assigned addresses.""" + specified = [dev for dev in cfg.get(CONF_DEVICES) if + dev.get(CONF_HOST) and dev.get(CONF_PORT)] + + while specified: + for dev in specified: + _LOGGER.debug("Discovering Konnected device %s at %s:%s", + dev.get(CONF_ID), + dev.get(CONF_HOST), + dev.get(CONF_PORT)) + try: + await hass.async_add_executor_job(setup_device, + dev.get(CONF_HOST), + dev.get(CONF_PORT)) + specified.remove(dev) + except konnected.Client.ClientError as err: + _LOGGER.error(err) + await asyncio.sleep(10) # try again in 10 seconds + + # Initialize devices specified in the configuration on boot for device in cfg.get(CONF_DEVICES): ConfiguredDevice(hass, device).save_data() @@ -128,6 +164,7 @@ async def async_setup(hass, config): device_discovered) hass.http.register_view(KonnectedView(access_token)) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, manual_discovery) return True @@ -188,6 +225,8 @@ class ConfiguredDevice: device_data = { CONF_BINARY_SENSORS: sensors, CONF_SWITCHES: actuators, + CONF_BLINK: self.config.get(CONF_BLINK), + CONF_DISCOVERY: self.config.get(CONF_DISCOVERY) } if CONF_DEVICES not in self.hass.data[DOMAIN]: @@ -271,7 +310,7 @@ class DiscoveredDevice: if sensor_config.get(CONF_INVERSE): state = not state - async_dispatcher_send( + dispatcher_send( self.hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state) @@ -306,13 +345,19 @@ class DiscoveredDevice: if (desired_sensor_configuration != current_sensor_configuration) or \ (current_actuator_config != desired_actuator_config) or \ - (current_api_endpoint != desired_api_endpoint): + (current_api_endpoint != desired_api_endpoint) or \ + (self.status.get(CONF_BLINK) != + self.stored_configuration.get(CONF_BLINK)) or \ + (self.status.get(CONF_DISCOVERY) != + self.stored_configuration.get(CONF_DISCOVERY)): _LOGGER.info('pushing settings to device %s', self.device_id) self.client.put_settings( desired_sensor_configuration, desired_actuator_config, self.hass.data[DOMAIN].get(CONF_ACCESS_TOKEN), - desired_api_endpoint + desired_api_endpoint, + blink=self.stored_configuration.get(CONF_BLINK), + discovery=self.stored_configuration.get(CONF_DISCOVERY) ) diff --git a/homeassistant/components/lifx/.translations/es.json b/homeassistant/components/lifx/.translations/es.json new file mode 100644 index 00000000000..f897c673432 --- /dev/null +++ b/homeassistant/components/lifx/.translations/es.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No se encontraron dispositivos LIFX en la red.", + "single_instance_allowed": "S\u00f3lo es posible una \u00fanica configuraci\u00f3n de LIFX." + }, + "step": { + "confirm": { + "description": "\u00bfQuieres configurar LIFX?", + "title": "LIFX" + } + }, + "title": "LIFX" + } +} \ No newline at end of file diff --git a/homeassistant/components/lifx/.translations/pt.json b/homeassistant/components/lifx/.translations/pt.json new file mode 100644 index 00000000000..d5c93c33993 --- /dev/null +++ b/homeassistant/components/lifx/.translations/pt.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nenhum dispositivo LIFX encontrado na rede.", + "single_instance_allowed": "Apenas uma configura\u00e7\u00e3o do LIFX \u00e9 permitida." + }, + "step": { + "confirm": { + "description": "Deseja configurar o LIFX?", + "title": "LIFX" + } + }, + "title": "LIFX" + } +} \ No newline at end of file diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py new file mode 100644 index 00000000000..1ca6c00b23a --- /dev/null +++ b/homeassistant/components/lifx/__init__.py @@ -0,0 +1,58 @@ +"""Component to embed LIFX.""" +import voluptuous as vol +import homeassistant.helpers.config_validation as cv + +from homeassistant import config_entries +from homeassistant.helpers import config_entry_flow +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN + + +DOMAIN = 'lifx' +REQUIREMENTS = ['aiolifx==0.6.5'] + +CONF_SERVER = 'server' +CONF_BROADCAST = 'broadcast' + +INTERFACE_SCHEMA = vol.Schema({ + vol.Optional(CONF_SERVER): cv.string, + vol.Optional(CONF_BROADCAST): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + LIGHT_DOMAIN: + vol.Schema(vol.All(cv.ensure_list, [INTERFACE_SCHEMA])), + } +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the LIFX component.""" + conf = config.get(DOMAIN) + + hass.data[DOMAIN] = conf or {} + + if conf is not None: + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT})) + + return True + + +async def async_setup_entry(hass, entry): + """Set up LIFX from a config entry.""" + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + entry, LIGHT_DOMAIN)) + return True + + +async def _async_has_devices(hass): + """Return if there are devices that can be discovered.""" + import aiolifx + + lifx_ip_addresses = await aiolifx.LifxScan(hass.loop).scan() + return len(lifx_ip_addresses) > 0 + + +config_entry_flow.register_discovery_flow( + DOMAIN, 'LIFX', _async_has_devices, config_entries.CONN_CLASS_LOCAL_POLL) diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json new file mode 100644 index 00000000000..300c9b628f3 --- /dev/null +++ b/homeassistant/components/lifx/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "title": "LIFX", + "step": { + "confirm": { + "title": "LIFX", + "description": "Do you want to set up LIFX?" + } + }, + "abort": { + "single_instance_allowed": "Only a single configuration of LIFX is possible.", + "no_devices_found": "No LIFX devices found on the network." + } + } +} diff --git a/homeassistant/components/light/abode.py b/homeassistant/components/light/abode.py index 69314b63a4b..397d61f3073 100644 --- a/homeassistant/components/light/abode.py +++ b/homeassistant/components/light/abode.py @@ -8,9 +8,10 @@ import logging from math import ceil from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_HS_COLOR, - SUPPORT_BRIGHTNESS, SUPPORT_COLOR, Light) -import homeassistant.util.color as color_util + ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_COLOR_TEMP, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, Light) +from homeassistant.util.color import ( + color_temperature_kelvin_to_mired, color_temperature_mired_to_kelvin) DEPENDENCIES = ['abode'] @@ -45,10 +46,13 @@ class AbodeLight(AbodeDevice, Light): def turn_on(self, **kwargs): """Turn on the light.""" - if (ATTR_HS_COLOR in kwargs and - self._device.is_dimmable and self._device.has_color): - self._device.set_color(color_util.color_hs_to_RGB( - *kwargs[ATTR_HS_COLOR])) + if ATTR_COLOR_TEMP in kwargs and self._device.is_color_capable: + self._device.set_color_temp( + int(color_temperature_mired_to_kelvin( + kwargs[ATTR_COLOR_TEMP]))) + + if ATTR_HS_COLOR in kwargs and self._device.is_color_capable: + self._device.set_color(kwargs[ATTR_HS_COLOR]) if ATTR_BRIGHTNESS in kwargs and self._device.is_dimmable: # Convert HASS brightness (0-255) to Abode brightness (0-99) @@ -77,18 +81,23 @@ class AbodeLight(AbodeDevice, Light): # Convert Abode brightness (0-99) to HASS brightness (0-255) return ceil(brightness * 255 / 99.0) + @property + def color_temp(self): + """Return the color temp of the light.""" + if self._device.has_color: + return color_temperature_kelvin_to_mired(self._device.color_temp) + @property def hs_color(self): """Return the color of the light.""" - if self._device.is_dimmable and self._device.has_color: - return color_util.color_RGB_to_hs(*self._device.color) + if self._device.has_color: + return self._device.color @property def supported_features(self): """Flag supported features.""" - if self._device.is_dimmable and self._device.has_color: - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR + if self._device.is_dimmable and self._device.is_color_capable: + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_COLOR_TEMP if self._device.is_dimmable: return SUPPORT_BRIGHTNESS - return 0 diff --git a/homeassistant/components/light/elkm1.py b/homeassistant/components/light/elkm1.py new file mode 100644 index 00000000000..707aedbb161 --- /dev/null +++ b/homeassistant/components/light/elkm1.py @@ -0,0 +1,59 @@ +""" +Support for control of ElkM1 lighting (X10, UPB, etc). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.elkm1/ +""" + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) +from homeassistant.components.elkm1 import ( + DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities) + +DEPENDENCIES = [ELK_DOMAIN] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the Elk light platform.""" + if discovery_info is None: + return + elk = hass.data[ELK_DOMAIN]['elk'] + async_add_entities( + create_elk_entities(hass, elk.lights, 'plc', ElkLight, []), True) + + +class ElkLight(ElkEntity, Light): + """Elk lighting device.""" + + def __init__(self, element, elk, elk_data): + """Initialize light.""" + super().__init__(element, elk, elk_data) + self._brightness = self._element.status + + @property + def brightness(self): + """Get the brightness.""" + return self._brightness + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS + + @property + def is_on(self) -> bool: + """Get the current brightness.""" + return self._brightness != 0 + + def _element_changed(self, element, changeset): + status = self._element.status if self._element.status != 1 else 100 + self._brightness = round(status * 2.55) + + async def async_turn_on(self, **kwargs): + """Turn on the light.""" + self._element.level(round(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55)) + + async def async_turn_off(self, **kwargs): + """Turn off the light.""" + self._element.level(0) diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index f389d34cd5d..cab6957c265 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util -REQUIREMENTS = ['flux_led==0.21'] +REQUIREMENTS = ['flux_led==0.22'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/light/homekit_controller.py b/homeassistant/components/light/homekit_controller.py index dd24e3cfb2e..b874bc49f0e 100644 --- a/homeassistant/components/light/homekit_controller.py +++ b/homeassistant/components/light/homekit_controller.py @@ -38,8 +38,7 @@ class HomeKitLight(HomeKitEntity, Light): def update_characteristics(self, characteristics): """Synchronise light state with Home Assistant.""" - # pylint: disable=import-error - import homekit + import homekit # pylint: disable=import-error for characteristic in characteristics: ctype = characteristic['type'] diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index 778d2fac59c..a1423cc6682 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -52,7 +52,7 @@ def async_add_entities_discovery(hass, discovery_info, async_add_entities): entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] - entities.append(KNXLight(hass, device)) + entities.append(KNXLight(device)) async_add_entities(entities) @@ -71,17 +71,15 @@ def async_add_entities_config(hass, config, async_add_entities): group_address_color=config.get(CONF_COLOR_ADDRESS), group_address_color_state=config.get(CONF_COLOR_STATE_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(light) - async_add_entities([KNXLight(hass, light)]) + async_add_entities([KNXLight(light)]) class KNXLight(Light): """Representation of a KNX light.""" - def __init__(self, hass, device): + def __init__(self, device): """Initialize of KNX light.""" self.device = device - self.hass = hass - self.async_register_callbacks() @callback def async_register_callbacks(self): @@ -91,6 +89,10 @@ class KNXLight(Light): await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) + async def async_added_to_hass(self): + """Store register state change callback.""" + self.async_register_callbacks() + @property def name(self): """Return the name of the KNX device.""" diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index 9dcd2ae4cc2..f346f88c42b 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -17,36 +17,32 @@ from homeassistant import util from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_TRANSITION, - ATTR_XY_COLOR, COLOR_GROUP, DOMAIN, LIGHT_TURN_ON_SCHEMA, PLATFORM_SCHEMA, + ATTR_XY_COLOR, COLOR_GROUP, DOMAIN, LIGHT_TURN_ON_SCHEMA, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_TRANSITION, VALID_BRIGHTNESS, VALID_BRIGHTNESS_PCT, Light, preprocess_turn_on_alternatives) +from homeassistant.components.lifx import ( + DOMAIN as LIFX_DOMAIN, CONF_SERVER, CONF_BROADCAST) from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.device_registry as dr from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.service import extract_entity_ids import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['aiolifx==0.6.3', 'aiolifx_effects==0.2.1'] +DEPENDENCIES = ['lifx'] +REQUIREMENTS = ['aiolifx_effects==0.2.1'] -UDP_BROADCAST_PORT = 56700 +SCAN_INTERVAL = timedelta(seconds=10) DISCOVERY_INTERVAL = 60 MESSAGE_TIMEOUT = 1.0 MESSAGE_RETRIES = 8 UNAVAILABLE_GRACE = 90 -CONF_SERVER = 'server' -CONF_BROADCAST = 'broadcast' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_SERVER, default='0.0.0.0'): cv.string, - vol.Optional(CONF_BROADCAST, default='255.255.255.255'): cv.string, -}) - SERVICE_LIFX_SET_STATE = 'lifx_set_state' ATTR_INFRARED = 'infrared' @@ -138,24 +134,41 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the LIFX platform.""" + """Set up the LIFX light platform. Obsolete.""" + _LOGGER.warning('LIFX no longer works with light platform configuration.') + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up LIFX from a config entry.""" if sys.platform == 'win32': _LOGGER.warning("The lifx platform is known to not work on Windows. " "Consider using the lifx_legacy platform instead") - server_addr = config.get(CONF_SERVER) + # Priority 1: manual config + interfaces = hass.data[LIFX_DOMAIN].get(DOMAIN) + if not interfaces: + # Priority 2: scanned interfaces + lifx_ip_addresses = await aiolifx().LifxScan(hass.loop).scan() + interfaces = [{CONF_SERVER: ip} for ip in lifx_ip_addresses] + if not interfaces: + # Priority 3: default interface + interfaces = [{}] lifx_manager = LIFXManager(hass, async_add_entities) - lifx_discovery = aiolifx().LifxDiscovery( - hass.loop, - lifx_manager, - discovery_interval=DISCOVERY_INTERVAL, - broadcast_ip=config.get(CONF_BROADCAST)) - coro = hass.loop.create_datagram_endpoint( - lambda: lifx_discovery, local_addr=(server_addr, UDP_BROADCAST_PORT)) + for interface in interfaces: + kwargs = {'discovery_interval': DISCOVERY_INTERVAL} + broadcast_ip = interface.get(CONF_BROADCAST) + if broadcast_ip: + kwargs['broadcast_ip'] = broadcast_ip + lifx_discovery = aiolifx().LifxDiscovery( + hass.loop, lifx_manager, **kwargs) - hass.async_add_job(coro) + kwargs = {} + listen_ip = interface.get(CONF_SERVER) + if listen_ip: + kwargs['listen_ip'] = listen_ip + lifx_discovery.start(**kwargs) @callback def cleanup(event): @@ -225,7 +238,7 @@ class LIFXManager: for light in self.service_to_entities(service): if service.service == SERVICE_LIFX_SET_STATE: task = light.set_state(**service.data) - tasks.append(self.hass.async_add_job(task)) + tasks.append(self.hass.async_create_task(task)) if tasks: await asyncio.wait(tasks, loop=self.hass.loop) @@ -392,6 +405,26 @@ class LIFXLight(Light): self.postponed_update = None self.lock = asyncio.Lock() + @property + def device_info(self): + """Return information about the device.""" + info = { + 'identifiers': { + (LIFX_DOMAIN, self.unique_id) + }, + 'name': self.name, + 'connections': { + (dr.CONNECTION_NETWORK_MAC, self.bulb.mac_addr) + }, + 'manufacturer': 'LIFX', + } + + model = aiolifx().products.product_map.get(self.bulb.product) + if model is not None: + info['model'] = model + + return info + @property def available(self): """Return the availability of the bulb.""" diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index a5aeabba84d..2e2971cfdc2 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -20,7 +20,7 @@ from homeassistant.util.color import ( color_temperature_mired_to_kelvin, color_hs_to_RGB) from homeassistant.helpers.restore_state import async_get_last_state -REQUIREMENTS = ['limitlessled==1.1.2'] +REQUIREMENTS = ['limitlessled==1.1.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt.py index 3b095aa4bfd..92030c8617a 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( ATTR_WHITE_VALUE, Light, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_COLOR, SUPPORT_WHITE_VALUE) from homeassistant.const import ( - CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_EFFECT, CONF_NAME, + CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_EFFECT, CONF_HS, CONF_NAME, CONF_OPTIMISTIC, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, STATE_ON, CONF_RGB, CONF_STATE, CONF_VALUE_TEMPLATE, CONF_WHITE_VALUE, CONF_XY) from homeassistant.components.mqtt import ( @@ -44,6 +44,9 @@ CONF_EFFECT_COMMAND_TOPIC = 'effect_command_topic' CONF_EFFECT_LIST = 'effect_list' CONF_EFFECT_STATE_TOPIC = 'effect_state_topic' CONF_EFFECT_VALUE_TEMPLATE = 'effect_value_template' +CONF_HS_COMMAND_TOPIC = 'hs_command_topic' +CONF_HS_STATE_TOPIC = 'hs_state_topic' +CONF_HS_VALUE_TEMPLATE = 'hs_value_template' CONF_RGB_COMMAND_TEMPLATE = 'rgb_command_template' CONF_RGB_COMMAND_TOPIC = 'rgb_command_topic' CONF_RGB_STATE_TOPIC = 'rgb_state_topic' @@ -82,6 +85,9 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_EFFECT_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_EFFECT_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_HS_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_HS_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_HS_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, @@ -143,6 +149,8 @@ async def _async_setup_entity(hass, config, async_add_entities, CONF_COMMAND_TOPIC, CONF_EFFECT_COMMAND_TOPIC, CONF_EFFECT_STATE_TOPIC, + CONF_HS_COMMAND_TOPIC, + CONF_HS_STATE_TOPIC, CONF_RGB_COMMAND_TOPIC, CONF_RGB_STATE_TOPIC, CONF_STATE_TOPIC, @@ -156,6 +164,7 @@ async def _async_setup_entity(hass, config, async_add_entities, CONF_BRIGHTNESS: config.get(CONF_BRIGHTNESS_VALUE_TEMPLATE), CONF_COLOR_TEMP: config.get(CONF_COLOR_TEMP_VALUE_TEMPLATE), CONF_EFFECT: config.get(CONF_EFFECT_VALUE_TEMPLATE), + CONF_HS: config.get(CONF_HS_VALUE_TEMPLATE), CONF_RGB: config.get(CONF_RGB_VALUE_TEMPLATE), CONF_RGB_COMMAND_TEMPLATE: config.get(CONF_RGB_COMMAND_TEMPLATE), CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), @@ -202,11 +211,17 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): self._optimistic_rgb = \ optimistic or topic[CONF_RGB_STATE_TOPIC] is None self._optimistic_brightness = ( - optimistic or topic[CONF_BRIGHTNESS_STATE_TOPIC] is None) + optimistic or + (topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None and + topic[CONF_BRIGHTNESS_STATE_TOPIC] is None) or + (topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is None and + topic[CONF_RGB_STATE_TOPIC] is None)) self._optimistic_color_temp = ( optimistic or topic[CONF_COLOR_TEMP_STATE_TOPIC] is None) self._optimistic_effect = ( optimistic or topic[CONF_EFFECT_STATE_TOPIC] is None) + self._optimistic_hs = \ + optimistic or topic[CONF_HS_STATE_TOPIC] is None self._optimistic_white_value = ( optimistic or topic[CONF_WHITE_VALUE_STATE_TOPIC] is None) self._optimistic_xy = \ @@ -222,7 +237,8 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): self._white_value = None self._supported_features = 0 self._supported_features |= ( - topic[CONF_RGB_COMMAND_TOPIC] is not None and SUPPORT_COLOR) + topic[CONF_RGB_COMMAND_TOPIC] is not None and + (SUPPORT_COLOR | SUPPORT_BRIGHTNESS)) self._supported_features |= ( topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None and SUPPORT_BRIGHTNESS) @@ -232,6 +248,8 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): self._supported_features |= ( topic[CONF_EFFECT_COMMAND_TOPIC] is not None and SUPPORT_EFFECT) + self._supported_features |= ( + topic[CONF_HS_COMMAND_TOPIC] is not None and SUPPORT_COLOR) self._supported_features |= ( topic[CONF_WHITE_VALUE_COMMAND_TOPIC] is not None and SUPPORT_WHITE_VALUE) @@ -312,6 +330,10 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): rgb = [int(val) for val in payload.split(',')] self._hs = color_util.color_RGB_to_hs(*rgb) + if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is None: + percent_bright = \ + float(color_util.color_RGB_to_hsv(*rgb)[2]) / 100.0 + self._brightness = int(percent_bright * 255) self.async_schedule_update_ha_state() if self._topic[CONF_RGB_STATE_TOPIC] is not None: @@ -374,6 +396,33 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): else: self._effect = None + @callback + def hs_received(topic, payload, qos): + """Handle new MQTT messages for hs color.""" + payload = templates[CONF_HS](payload) + if not payload: + _LOGGER.debug("Ignoring empty hs message from '%s'", topic) + return + + try: + hs_color = [float(val) for val in payload.split(',', 2)] + self._hs = hs_color + self.async_schedule_update_ha_state() + except ValueError: + _LOGGER.debug("Failed to parse hs state update: '%s'", + payload) + + if self._topic[CONF_HS_STATE_TOPIC] is not None: + await mqtt.async_subscribe( + self.hass, self._topic[CONF_HS_STATE_TOPIC], hs_received, + self._qos) + self._hs = (0, 0) + if self._optimistic_hs and last_state\ + and last_state.attributes.get(ATTR_HS_COLOR): + self._hs = last_state.attributes.get(ATTR_HS_COLOR) + elif self._topic[CONF_HS_COMMAND_TOPIC] is not None: + self._hs = (0, 0) + @callback def white_value_received(topic, payload, qos): """Handle new MQTT messages for white value.""" @@ -403,7 +452,7 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): @callback def xy_received(topic, payload, qos): - """Handle new MQTT messages for color.""" + """Handle new MQTT messages for xy color.""" payload = templates[CONF_XY](payload) if not payload: _LOGGER.debug("Ignoring empty xy-color message from '%s'", @@ -539,6 +588,19 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): self._hs = kwargs[ATTR_HS_COLOR] should_update = True + if ATTR_HS_COLOR in kwargs and \ + self._topic[CONF_HS_COMMAND_TOPIC] is not None: + + hs_color = kwargs[ATTR_HS_COLOR] + mqtt.async_publish( + self.hass, self._topic[CONF_HS_COMMAND_TOPIC], + '{},{}'.format(*hs_color), self._qos, + self._retain) + + if self._optimistic_hs: + self._hs = kwargs[ATTR_HS_COLOR] + should_update = True + if ATTR_HS_COLOR in kwargs and \ self._topic[CONF_XY_COMMAND_TOPIC] is not None: @@ -563,6 +625,27 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): if self._optimistic_brightness: self._brightness = kwargs[ATTR_BRIGHTNESS] should_update = True + elif ATTR_BRIGHTNESS in kwargs and ATTR_HS_COLOR not in kwargs and\ + self._topic[CONF_RGB_COMMAND_TOPIC] is not None: + rgb = color_util.color_hsv_to_RGB( + self._hs[0], self._hs[1], kwargs[ATTR_BRIGHTNESS] / 255 * 100) + tpl = self._templates[CONF_RGB_COMMAND_TEMPLATE] + if tpl: + rgb_color_str = tpl.async_render({ + 'red': rgb[0], + 'green': rgb[1], + 'blue': rgb[2], + }) + else: + rgb_color_str = '{},{},{}'.format(*rgb) + + mqtt.async_publish( + self.hass, self._topic[CONF_RGB_COMMAND_TOPIC], + rgb_color_str, self._qos, self._retain) + + if self._optimistic_brightness: + self._brightness = kwargs[ATTR_BRIGHTNESS] + should_update = True if ATTR_COLOR_TEMP in kwargs and \ self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None: diff --git a/homeassistant/components/light/opple.py b/homeassistant/components/light/opple.py index 66850d04406..fb503d33d31 100644 --- a/homeassistant/components/light/opple.py +++ b/homeassistant/components/light/opple.py @@ -110,7 +110,7 @@ class OppleLight(Light): self._device.brightness = kwargs[ATTR_BRIGHTNESS] if ATTR_COLOR_TEMP in kwargs and \ - self.brightness != kwargs[ATTR_COLOR_TEMP]: + self.color_temp != kwargs[ATTR_COLOR_TEMP]: color_temp = mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]) self._device.color_temperature = color_temp diff --git a/homeassistant/components/light/osramlightify.py b/homeassistant/components/light/osramlightify.py index 244a233c517..a49e12c76a6 100644 --- a/homeassistant/components/light/osramlightify.py +++ b/homeassistant/components/light/osramlightify.py @@ -231,6 +231,11 @@ class OsramLightifyLight(Luminary): self._luminary.temp()) self._brightness = int(self._luminary.lum() * 2.55) + @property + def unique_id(self): + """Return a unique ID.""" + return self._light_id + class OsramLightifyGroup(Luminary): """Representation of an Osram Lightify Group.""" @@ -240,6 +245,7 @@ class OsramLightifyGroup(Luminary): self._bridge = bridge self._light_ids = [] super().__init__(group, update_lights) + self._unique_id = '{}'.format(self._light_ids) def _get_state(self): """Get state of group.""" @@ -260,3 +266,8 @@ class OsramLightifyGroup(Luminary): else: self._temperature = color_temperature_kelvin_to_mired(o_temp) self._state = light.on() + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id diff --git a/homeassistant/components/light/rflink.py b/homeassistant/components/light/rflink.py index d9f9dd589ec..3b60280c582 100644 --- a/homeassistant/components/light/rflink.py +++ b/homeassistant/components/light/rflink.py @@ -6,19 +6,19 @@ https://home-assistant.io/components/light.rflink/ """ import logging +import voluptuous as vol + from homeassistant.components.light import ( - ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) + ATTR_BRIGHTNESS, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, Light) from homeassistant.components.rflink import ( CONF_ALIASES, CONF_ALIASSES, CONF_AUTOMATIC_ADD, CONF_DEVICE_DEFAULTS, CONF_DEVICES, CONF_FIRE_EVENT, CONF_GROUP, CONF_GROUP_ALIASES, - CONF_GROUP_ALIASSES, CONF_IGNORE_DEVICES, CONF_NOGROUP_ALIASES, - CONF_NOGROUP_ALIASSES, CONF_SIGNAL_REPETITIONS, DATA_DEVICE_REGISTER, - DATA_ENTITY_GROUP_LOOKUP, DATA_ENTITY_LOOKUP, DEVICE_DEFAULTS_SCHEMA, - DOMAIN, EVENT_KEY_COMMAND, EVENT_KEY_ID, SwitchableRflinkDevice, cv, - remove_deprecated, vol) -from homeassistant.const import ( - CONF_NAME, CONF_PLATFORM, CONF_TYPE, STATE_UNKNOWN) -from homeassistant.helpers.deprecation import get_deprecated + CONF_GROUP_ALIASSES, CONF_NOGROUP_ALIASES, CONF_NOGROUP_ALIASSES, + CONF_SIGNAL_REPETITIONS, DATA_DEVICE_REGISTER, DEVICE_DEFAULTS_SCHEMA, + EVENT_KEY_COMMAND, EVENT_KEY_ID, SwitchableRflinkDevice, + remove_deprecated) +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_NAME, CONF_TYPE) DEPENDENCIES = ['rflink'] @@ -29,14 +29,12 @@ TYPE_SWITCHABLE = 'switchable' TYPE_HYBRID = 'hybrid' TYPE_TOGGLE = 'toggle' -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): DOMAIN, - vol.Optional(CONF_IGNORE_DEVICES): vol.All(cv.ensure_list, [cv.string]), +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({})): - DEVICE_DEFAULTS_SCHEMA, + DEVICE_DEFAULTS_SCHEMA, vol.Optional(CONF_AUTOMATIC_ADD, default=True): cv.boolean, - vol.Optional(CONF_DEVICES, default={}): vol.Schema({ - cv.string: { + vol.Optional(CONF_DEVICES, default={}): { + cv.string: vol.Schema({ vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_TYPE): vol.Any(TYPE_DIMMABLE, TYPE_SWITCHABLE, @@ -57,9 +55,9 @@ PLATFORM_SCHEMA = vol.Schema({ vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_NOGROUP_ALIASSES): vol.All(cv.ensure_list, [cv.string]), - }, - }), -}) + }) + }, +}, extra=vol.ALLOW_EXTRA) def entity_type_for_device_id(device_id): @@ -98,7 +96,7 @@ def entity_class_for_type(entity_type): return entity_device_mapping.get(entity_type, RflinkLight) -def devices_from_config(domain_config, hass=None): +def devices_from_config(domain_config): """Parse configuration and add Rflink light devices.""" devices = [] for device_id, config in domain_config[CONF_DEVICES].items(): @@ -124,40 +122,16 @@ def devices_from_config(domain_config, hass=None): "repetitions. Please set 'dimmable' or 'switchable' " "type explicitly in configuration", device_id) - device = entity_class(device_id, hass, **device_config) + device = entity_class(device_id, **device_config) devices.append(device) - # Register entity (and aliases) to listen to incoming rflink events - - # Device id and normal aliases respond to normal and group command - hass.data[DATA_ENTITY_LOOKUP][ - EVENT_KEY_COMMAND][device_id].append(device) - if config[CONF_GROUP]: - hass.data[DATA_ENTITY_GROUP_LOOKUP][ - EVENT_KEY_COMMAND][device_id].append(device) - for _id in get_deprecated(config, CONF_ALIASES, CONF_ALIASSES): - hass.data[DATA_ENTITY_LOOKUP][ - EVENT_KEY_COMMAND][_id].append(device) - hass.data[DATA_ENTITY_GROUP_LOOKUP][ - EVENT_KEY_COMMAND][_id].append(device) - # group_aliases only respond to group commands - for _id in get_deprecated( - config, CONF_GROUP_ALIASES, CONF_GROUP_ALIASSES): - hass.data[DATA_ENTITY_GROUP_LOOKUP][ - EVENT_KEY_COMMAND][_id].append(device) - # nogroup_aliases only respond to normal commands - for _id in get_deprecated( - config, CONF_NOGROUP_ALIASES, CONF_NOGROUP_ALIASSES): - hass.data[DATA_ENTITY_LOOKUP][ - EVENT_KEY_COMMAND][_id].append(device) - return devices async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Rflink light platform.""" - async_add_entities(devices_from_config(config, hass)) + async_add_entities(devices_from_config(config)) async def add_new_device(event): """Check if device is known, otherwise add to list of known devices.""" @@ -167,16 +141,9 @@ async def async_setup_platform(hass, config, async_add_entities, entity_class = entity_class_for_type(entity_type) device_config = config[CONF_DEVICE_DEFAULTS] - device = entity_class(device_id, hass, **device_config) + device = entity_class(device_id, initial_event=event, **device_config) async_add_entities([device]) - # Register entity to listen to incoming Rflink events - hass.data[DATA_ENTITY_LOOKUP][ - EVENT_KEY_COMMAND][device_id].append(device) - - # Schedule task to process event after entity is created - hass.async_add_job(device.handle_event, event) - if config[CONF_AUTOMATIC_ADD]: hass.data[DATA_DEVICE_REGISTER][EVENT_KEY_COMMAND] = add_new_device @@ -277,7 +244,7 @@ class ToggleRflinkLight(SwitchableRflinkDevice, Light): if command == 'on': # if the state is unknown or false, it gets set as true # if the state is true, it gets set as false - self._state = self._state in [STATE_UNKNOWN, False] + self._state = self._state in [None, False] async def async_turn_on(self, **kwargs): """Turn the device on.""" diff --git a/homeassistant/components/light/template.py b/homeassistant/components/light/template.py index 8aff85c6001..2447dabe3c7 100644 --- a/homeassistant/components/light/template.py +++ b/homeassistant/components/light/template.py @@ -215,8 +215,8 @@ class LightTemplate(Light): optimistic_set = True if ATTR_BRIGHTNESS in kwargs and self._level_script: - self.hass.async_create_task(self._level_script.async_run( - {"brightness": kwargs[ATTR_BRIGHTNESS]})) + await self._level_script.async_run( + {"brightness": kwargs[ATTR_BRIGHTNESS]}, context=self._context) else: await self._on_script.async_run() @@ -225,7 +225,7 @@ class LightTemplate(Light): async def async_turn_off(self, **kwargs): """Turn the light off.""" - await self._off_script.async_run() + await self._off_script.async_run(context=self._context) if self._template is None: self._state = False self.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/tuya.py b/homeassistant/components/light/tuya.py index 0dc2cacc1e0..0a1468a6a51 100644 --- a/homeassistant/components/light/tuya.py +++ b/homeassistant/components/light/tuya.py @@ -40,17 +40,17 @@ class TuyaLight(TuyaDevice, Light): @property def brightness(self): """Return the brightness of the light.""" - return self.tuya.brightness() + return int(self.tuya.brightness()) @property def hs_color(self): """Return the hs_color of the light.""" - return self.tuya.hs_color() + return tuple(map(int, self.tuya.hs_color())) @property def color_temp(self): """Return the color_temp of the light.""" - color_temp = self.tuya.color_temp() + color_temp = int(self.tuya.color_temp()) if color_temp is None: return None return colorutil.color_temperature_kelvin_to_mired(color_temp) diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index 8bc2497a3e5..26a63b2f16b 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -5,23 +5,24 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/light.xiaomi_miio/ """ import asyncio +import datetime +from datetime import timedelta from functools import partial import logging from math import ceil -from datetime import timedelta -import datetime import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.light import ( - PLATFORM_SCHEMA, ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, - ATTR_COLOR_TEMP, SUPPORT_COLOR_TEMP, Light, ATTR_ENTITY_ID, DOMAIN, ) - -from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, ) + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_ENTITY_ID, DOMAIN, PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, Light) +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv from homeassistant.util import dt +REQUIREMENTS = ['python-miio==0.4.2', 'construct==2.9.45'] + _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Xiaomi Philips Light' @@ -39,11 +40,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 'philips.light.zyceiling', 'philips.light.bulb', 'philips.light.candle', - 'philips.light.candle2']), + 'philips.light.candle2', + 'philips.light.mono1']), }) -REQUIREMENTS = ['python-miio==0.4.1', 'construct==2.9.41'] - # The light does not accept cct values < 1 CCT_MIN = 1 CCT_MAX = 100 @@ -157,6 +157,12 @@ async def async_setup_platform(hass, config, async_add_entities, device = XiaomiPhilipsBulb(name, light, model, unique_id) devices.append(device) hass.data[DATA_KEY][host] = device + elif model == 'philips.light.mono1': + from miio import PhilipsBulb + light = PhilipsBulb(host, token) + device = XiaomiPhilipsGenericLight(name, light, model, unique_id) + devices.append(device) + hass.data[DATA_KEY][host] = device else: _LOGGER.error( 'Unsupported device found! Please create an issue at ' diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index b14b1f96e69..7efd62e3de5 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -20,7 +20,7 @@ from homeassistant.components.light import ( import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util -REQUIREMENTS = ['yeelight==0.4.0'] +REQUIREMENTS = ['yeelight==0.4.3'] _LOGGER = logging.getLogger(__name__) @@ -35,6 +35,7 @@ LEGACY_DEVICE_TYPE_MAP = { DEFAULT_NAME = 'Yeelight' DEFAULT_TRANSITION = 350 +CONF_MODEL = 'model' CONF_TRANSITION = 'transition' CONF_SAVE_ON_CHANGE = 'save_on_change' CONF_MODE_MUSIC = 'use_music_mode' @@ -46,6 +47,7 @@ DEVICE_SCHEMA = vol.Schema({ vol.Optional(CONF_TRANSITION, default=DEFAULT_TRANSITION): cv.positive_int, vol.Optional(CONF_MODE_MUSIC, default=False): cv.boolean, vol.Optional(CONF_SAVE_ON_CHANGE, default=False): cv.boolean, + vol.Optional(CONF_MODEL): cv.string, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -55,15 +57,14 @@ SUPPORT_YEELIGHT = (SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION | SUPPORT_FLASH) +SUPPORT_YEELIGHT_WHITE_TEMP = (SUPPORT_YEELIGHT | + SUPPORT_COLOR_TEMP) + SUPPORT_YEELIGHT_RGB = (SUPPORT_YEELIGHT | SUPPORT_COLOR | SUPPORT_EFFECT | SUPPORT_COLOR_TEMP) -YEELIGHT_MIN_KELVIN = YEELIGHT_MAX_KELVIN = 2700 -YEELIGHT_RGB_MIN_KELVIN = 1700 -YEELIGHT_RGB_MAX_KELVIN = 6500 - EFFECT_DISCO = "Disco" EFFECT_TEMP = "Slow Temp" EFFECT_STROBE = "Strobe epilepsy!" @@ -132,23 +133,26 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.debug("Adding autodetected %s", discovery_info['hostname']) device_type = discovery_info['device_type'] - device_type = LEGACY_DEVICE_TYPE_MAP.get(device_type, device_type) + legacy_device_type = LEGACY_DEVICE_TYPE_MAP.get(device_type, + device_type) # Not using hostname, as it seems to vary. - name = "yeelight_%s_%s" % (device_type, + name = "yeelight_%s_%s" % (legacy_device_type, discovery_info['properties']['mac']) - host = discovery_info['host'] - device = {'name': name, 'ipaddr': host} + device = {'name': name, 'ipaddr': discovery_info['host']} - light = YeelightLight(device, DEVICE_SCHEMA({})) + light = YeelightLight(device, DEVICE_SCHEMA({CONF_MODEL: device_type})) lights.append(light) - hass.data[DATA_KEY][host] = light + hass.data[DATA_KEY][name] = light else: - for host, device_config in config[CONF_DEVICES].items(): - device = {'name': device_config[CONF_NAME], 'ipaddr': host} + for ipaddr, device_config in config[CONF_DEVICES].items(): + name = device_config[CONF_NAME] + _LOGGER.debug("Adding configured %s", name) + + device = {'name': name, 'ipaddr': ipaddr} light = YeelightLight(device, device_config) lights.append(light) - hass.data[DATA_KEY][host] = light + hass.data[DATA_KEY][name] = light add_entities(lights, True) @@ -194,6 +198,10 @@ class YeelightLight(Light): self._is_on = None self._hs = None + self._model = config.get('model') + self._min_mireds = None + self._max_mireds = None + @property def available(self) -> bool: """Return if bulb is available.""" @@ -232,16 +240,12 @@ class YeelightLight(Light): @property def min_mireds(self): """Return minimum supported color temperature.""" - if self.supported_features & SUPPORT_COLOR_TEMP: - return kelvin_to_mired(YEELIGHT_RGB_MAX_KELVIN) - return kelvin_to_mired(YEELIGHT_MAX_KELVIN) + return self._min_mireds @property def max_mireds(self): """Return maximum supported color temperature.""" - if self.supported_features & SUPPORT_COLOR_TEMP: - return kelvin_to_mired(YEELIGHT_RGB_MIN_KELVIN) - return kelvin_to_mired(YEELIGHT_MIN_KELVIN) + return self._max_mireds def _get_hs_from_properties(self): rgb = self._properties.get('rgb', None) @@ -279,7 +283,8 @@ class YeelightLight(Light): import yeelight if self._bulb_device is None: try: - self._bulb_device = yeelight.Bulb(self._ipaddr) + self._bulb_device = yeelight.Bulb(self._ipaddr, + model=self._model) self._bulb_device.get_properties() # force init for type self._available = True @@ -305,6 +310,15 @@ class YeelightLight(Light): if self._bulb_device.bulb_type == yeelight.BulbType.Color: self._supported_features = SUPPORT_YEELIGHT_RGB + elif self._bulb_device.bulb_type == yeelight.BulbType.WhiteTemp: + self._supported_features = SUPPORT_YEELIGHT_WHITE_TEMP + + if self._min_mireds is None: + model_specs = self._bulb.get_model_specs() + self._min_mireds = \ + kelvin_to_mired(model_specs['color_temp']['max']) + self._max_mireds = \ + kelvin_to_mired(model_specs['color_temp']['min']) self._is_on = self._properties.get('power') == 'on' @@ -500,5 +514,6 @@ class YeelightLight(Light): import yeelight try: self._bulb.set_power_mode(yeelight.enums.PowerMode[mode.upper()]) + self.async_schedule_update_ha_state(True) except yeelight.BulbException as ex: _LOGGER.error("Unable to set the power mode: %s", ex) diff --git a/homeassistant/components/light/zwave.py b/homeassistant/components/light/zwave.py index 1e768eb127a..09f3709f216 100644 --- a/homeassistant/components/light/zwave.py +++ b/homeassistant/components/light/zwave.py @@ -7,13 +7,14 @@ https://home-assistant.io/components/light.zwave/ import logging from threading import Timer +from homeassistant.core import callback from homeassistant.components.light import ( ATTR_WHITE_VALUE, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR, SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE, DOMAIN, Light) from homeassistant.components import zwave -from homeassistant.components.zwave import async_setup_platform # noqa pylint: disable=unused-import from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -43,6 +44,22 @@ TEMP_WARM_HASS = (TEMP_COLOR_MAX - TEMP_COLOR_MIN) / 3 * 2 + TEMP_COLOR_MIN TEMP_COLD_HASS = (TEMP_COLOR_MAX - TEMP_COLOR_MIN) / 3 + TEMP_COLOR_MIN +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old method of setting up Z-Wave lights.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Z-Wave Light from Config Entry.""" + @callback + def async_add_light(light): + """Add Z-Wave Light.""" + async_add_entities([light]) + + async_dispatcher_connect(hass, 'zwave_new_light', async_add_light) + + def get_device(node, values, node_config, **kwargs): """Create Z-Wave entity device.""" refresh = node_config.get(zwave.CONF_REFRESH_VALUE) diff --git a/homeassistant/components/lock/august.py b/homeassistant/components/lock/august.py index 7aec3c78690..e8949255ee9 100644 --- a/homeassistant/components/lock/august.py +++ b/homeassistant/components/lock/august.py @@ -4,12 +4,15 @@ Support for August lock. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/lock.august/ """ +import logging from datetime import timedelta from homeassistant.components.august import DATA_AUGUST from homeassistant.components.lock import LockDevice from homeassistant.const import ATTR_BATTERY_LEVEL +_LOGGER = logging.getLogger(__name__) + DEPENDENCIES = ['august'] SCAN_INTERVAL = timedelta(seconds=5) @@ -21,6 +24,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): devices = [] for lock in data.locks: + _LOGGER.debug("Adding lock for %s", lock.device_name) devices.append(AugustLock(data, lock)) add_entities(devices, True) @@ -77,6 +81,9 @@ class AugustLock(LockDevice): @property def device_state_attributes(self): """Return the device specific state attributes.""" + if self._lock_detail is None: + return None + return { ATTR_BATTERY_LEVEL: self._lock_detail.battery_level, } diff --git a/homeassistant/components/lock/template.py b/homeassistant/components/lock/template.py new file mode 100644 index 00000000000..527af4c5b85 --- /dev/null +++ b/homeassistant/components/lock/template.py @@ -0,0 +1,141 @@ +""" +Support for locks which integrates with other components. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.template/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv + +from homeassistant.core import callback +from homeassistant.components.lock import (LockDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE, + EVENT_HOMEASSISTANT_START, STATE_ON, STATE_LOCKED, MATCH_ALL) +from homeassistant.exceptions import TemplateError +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.script import Script + +_LOGGER = logging.getLogger(__name__) + +CONF_LOCK = 'lock' +CONF_UNLOCK = 'unlock' + +DEFAULT_NAME = 'Template Lock' +DEFAULT_OPTIMISTIC = False + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, + vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, + vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean +}) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the Template lock.""" + name = config.get(CONF_NAME) + value_template = config.get(CONF_VALUE_TEMPLATE) + value_template.hass = hass + value_template_entity_ids = value_template.extract_entities() + + if value_template_entity_ids == MATCH_ALL: + _LOGGER.warning( + 'Template lock %s has no entity ids configured to track nor ' + 'were we able to extract the entities to track from the %s ' + 'template. This entity will only be able to be updated ' + 'manually.', name, CONF_VALUE_TEMPLATE) + + async_add_devices([TemplateLock( + hass, + name, + value_template, + value_template_entity_ids, + config.get(CONF_LOCK), + config.get(CONF_UNLOCK), + config.get(CONF_OPTIMISTIC) + )]) + + +class TemplateLock(LockDevice): + """Representation of a template lock.""" + + def __init__(self, hass, name, value_template, entity_ids, + command_lock, command_unlock, optimistic): + """Initialize the lock.""" + self._state = None + self._hass = hass + self._name = name + self._state_template = value_template + self._state_entities = entity_ids + self._command_lock = Script(hass, command_lock) + self._command_unlock = Script(hass, command_unlock) + self._optimistic = optimistic + + async def async_added_to_hass(self): + """Register callbacks.""" + @callback + def template_lock_state_listener(entity, old_state, new_state): + """Handle target device state changes.""" + self.async_schedule_update_ha_state(True) + + @callback + def template_lock_startup(event): + """Update template on startup.""" + if self._state_entities != MATCH_ALL: + # Track state change only for valid templates + async_track_state_change( + self._hass, self._state_entities, + template_lock_state_listener) + self.async_schedule_update_ha_state(True) + + self._hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, template_lock_startup) + + @property + def assumed_state(self): + """Return true if we do optimistic updates.""" + return self._optimistic + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the lock.""" + return self._name + + @property + def is_locked(self): + """Return true if lock is locked.""" + return self._state + + async def async_update(self): + """Update the state from the template.""" + try: + self._state = self._state_template.async_render().lower() in ( + 'true', STATE_ON, STATE_LOCKED) + except TemplateError as ex: + self._state = None + _LOGGER.error('Could not render template %s: %s', self._name, ex) + + async def async_lock(self, **kwargs): + """Lock the device.""" + if self._optimistic: + self._state = True + self.async_schedule_update_ha_state() + await self._command_lock.async_run(context=self._context) + + async def async_unlock(self, **kwargs): + """Unlock the device.""" + if self._optimistic: + self._state = False + self.async_schedule_update_ha_state() + await self._command_unlock.async_run(context=self._context) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 5cbd2b9432b..5bd7ed0d2f5 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -14,13 +14,16 @@ from homeassistant.loader import bind_hass from homeassistant.components import sun from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( - ATTR_DOMAIN, ATTR_ENTITY_ID, ATTR_HIDDEN, ATTR_NAME, CONF_EXCLUDE, - CONF_INCLUDE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - EVENT_LOGBOOK_ENTRY, EVENT_STATE_CHANGED, HTTP_BAD_REQUEST, STATE_NOT_HOME, - STATE_OFF, STATE_ON) + ATTR_DOMAIN, ATTR_ENTITY_ID, ATTR_HIDDEN, ATTR_NAME, ATTR_SERVICE, + CONF_EXCLUDE, CONF_INCLUDE, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, EVENT_LOGBOOK_ENTRY, EVENT_STATE_CHANGED, + HTTP_BAD_REQUEST, STATE_NOT_HOME, STATE_OFF, STATE_ON) from homeassistant.core import ( DOMAIN as HA_DOMAIN, State, callback, split_entity_id) from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME +from homeassistant.components.homekit.const import ( + ATTR_DISPLAY_NAME, ATTR_VALUE, DOMAIN as DOMAIN_HOMEKIT, + EVENT_HOMEKIT_CHANGED) import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util @@ -56,7 +59,7 @@ CONFIG_SCHEMA = vol.Schema({ ALL_EVENT_TYPES = [ EVENT_STATE_CHANGED, EVENT_LOGBOOK_ENTRY, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - EVENT_ALEXA_SMART_HOME + EVENT_ALEXA_SMART_HOME, EVENT_HOMEKIT_CHANGED ] LOG_MESSAGE_SCHEMA = vol.Schema({ @@ -294,6 +297,25 @@ def humanify(hass, events): 'context_user_id': event.context.user_id } + elif event.event_type == EVENT_HOMEKIT_CHANGED: + data = event.data + entity_id = data.get(ATTR_ENTITY_ID) + value = data.get(ATTR_VALUE) + + value_msg = " to {}".format(value) if value else '' + message = "send command {}{} for {}".format( + data[ATTR_SERVICE], value_msg, data[ATTR_DISPLAY_NAME]) + + yield { + 'when': event.time_fired, + 'name': 'HomeKit', + 'message': message, + 'domain': DOMAIN_HOMEKIT, + 'entity_id': entity_id, + 'context_id': event.context.id, + 'context_user_id': event.context.user_id + } + def _get_events(hass, config, start_day, end_day, entity_id=None): """Get events for a period of time.""" diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index a24c8eb9e91..141f3c98334 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -1,19 +1,240 @@ """Lovelace UI.""" +import logging +import uuid +import os +from os import O_CREAT, O_TRUNC, O_WRONLY +from collections import OrderedDict +from typing import Dict, List, Union + import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.util.yaml import load_yaml from homeassistant.exceptions import HomeAssistantError +_LOGGER = logging.getLogger(__name__) DOMAIN = 'lovelace' +REQUIREMENTS = ['ruamel.yaml==0.15.72'] + +LOVELACE_CONFIG_FILE = 'ui-lovelace.yaml' +JSON_TYPE = Union[List, Dict, str] # pylint: disable=invalid-name + +FORMAT_YAML = 'yaml' +FORMAT_JSON = 'json' OLD_WS_TYPE_GET_LOVELACE_UI = 'frontend/lovelace_config' WS_TYPE_GET_LOVELACE_UI = 'lovelace/config' + +WS_TYPE_GET_CARD = 'lovelace/config/card/get' +WS_TYPE_UPDATE_CARD = 'lovelace/config/card/update' +WS_TYPE_ADD_CARD = 'lovelace/config/card/add' + SCHEMA_GET_LOVELACE_UI = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): vol.Any(WS_TYPE_GET_LOVELACE_UI, OLD_WS_TYPE_GET_LOVELACE_UI), }) +SCHEMA_GET_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_GET_CARD, + vol.Required('card_id'): str, + vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, + FORMAT_YAML), +}) + +SCHEMA_UPDATE_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_UPDATE_CARD, + vol.Required('card_id'): str, + vol.Required('card_config'): vol.Any(str, Dict), + vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, + FORMAT_YAML), +}) + +SCHEMA_ADD_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_ADD_CARD, + vol.Required('view_id'): str, + vol.Required('card_config'): vol.Any(str, Dict), + vol.Optional('position'): int, + vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, + FORMAT_YAML), +}) + + +class WriteError(HomeAssistantError): + """Error writing the data.""" + + +class CardNotFoundError(HomeAssistantError): + """Card not found in data.""" + + +class ViewNotFoundError(HomeAssistantError): + """View not found in data.""" + + +class UnsupportedYamlError(HomeAssistantError): + """Unsupported YAML.""" + + +def save_yaml(fname: str, data: JSON_TYPE): + """Save a YAML file.""" + from ruamel.yaml import YAML + from ruamel.yaml.error import YAMLError + yaml = YAML(typ='rt') + yaml.indent(sequence=4, offset=2) + tmp_fname = fname + "__TEMP__" + try: + with open(os.open(tmp_fname, O_WRONLY | O_CREAT | O_TRUNC, 0o644), + 'w', encoding='utf-8') as temp_file: + yaml.dump(data, temp_file) + os.replace(tmp_fname, fname) + except YAMLError as exc: + _LOGGER.error(str(exc)) + raise HomeAssistantError(exc) + except OSError as exc: + _LOGGER.exception('Saving YAML file %s failed: %s', fname, exc) + raise WriteError(exc) + finally: + if os.path.exists(tmp_fname): + try: + os.remove(tmp_fname) + except OSError as exc: + # If we are cleaning up then something else went wrong, so + # we should suppress likely follow-on errors in the cleanup + _LOGGER.error("YAML replacement cleanup failed: %s", exc) + + +def _yaml_unsupported(loader, node): + raise UnsupportedYamlError( + 'Unsupported YAML, you can not use {} in ui-lovelace.yaml' + .format(node.tag)) + + +def load_yaml(fname: str) -> JSON_TYPE: + """Load a YAML file.""" + from ruamel.yaml import YAML + from ruamel.yaml.constructor import RoundTripConstructor + from ruamel.yaml.error import YAMLError + + RoundTripConstructor.add_constructor(None, _yaml_unsupported) + + yaml = YAML(typ='rt') + + try: + with open(fname, encoding='utf-8') as conf_file: + # If configuration file is empty YAML returns None + # We convert that to an empty dict + return yaml.load(conf_file) or OrderedDict() + except YAMLError as exc: + _LOGGER.error("YAML error in %s: %s", fname, exc) + raise HomeAssistantError(exc) + except UnicodeDecodeError as exc: + _LOGGER.error("Unable to read file %s: %s", fname, exc) + raise HomeAssistantError(exc) + + +def load_config(fname: str) -> JSON_TYPE: + """Load a YAML file and adds id to views and cards if not present.""" + config = load_yaml(fname) + # Check if all views and cards have an id or else add one + updated = False + index = 0 + for view in config.get('views', []): + if 'id' not in view: + updated = True + view.insert(0, 'id', index, + comment="Automatically created id") + for card in view.get('cards', []): + if 'id' not in card: + updated = True + card.insert(0, 'id', uuid.uuid4().hex, + comment="Automatically created id") + index += 1 + if updated: + save_yaml(fname, config) + return config + + +def object_to_yaml(data: JSON_TYPE) -> str: + """Create yaml string from object.""" + from ruamel.yaml import YAML + from ruamel.yaml.error import YAMLError + from ruamel.yaml.compat import StringIO + yaml = YAML(typ='rt') + yaml.indent(sequence=4, offset=2) + stream = StringIO() + try: + yaml.dump(data, stream) + return stream.getvalue() + except YAMLError as exc: + _LOGGER.error("YAML error: %s", exc) + raise HomeAssistantError(exc) + + +def yaml_to_object(data: str) -> JSON_TYPE: + """Create object from yaml string.""" + from ruamel.yaml import YAML + from ruamel.yaml.error import YAMLError + yaml = YAML(typ='rt') + try: + return yaml.load(data) + except YAMLError as exc: + _LOGGER.error("YAML error: %s", exc) + raise HomeAssistantError(exc) + + +def get_card(fname: str, card_id: str, data_format: str = FORMAT_YAML)\ + -> JSON_TYPE: + """Load a specific card config for id.""" + config = load_yaml(fname) + for view in config.get('views', []): + for card in view.get('cards', []): + if card.get('id') != card_id: + continue + if data_format == FORMAT_YAML: + return object_to_yaml(card) + return card + + raise CardNotFoundError( + "Card with ID: {} was not found in {}.".format(card_id, fname)) + + +def update_card(fname: str, card_id: str, card_config: str, + data_format: str = FORMAT_YAML): + """Save a specific card config for id.""" + config = load_yaml(fname) + for view in config.get('views', []): + for card in view.get('cards', []): + if card.get('id') != card_id: + continue + if data_format == FORMAT_YAML: + card_config = yaml_to_object(card_config) + card.update(card_config) + save_yaml(fname, config) + return + + raise CardNotFoundError( + "Card with ID: {} was not found in {}.".format(card_id, fname)) + + +def add_card(fname: str, view_id: str, card_config: str, + position: int = None, data_format: str = FORMAT_YAML): + """Add a card to a view.""" + config = load_yaml(fname) + for view in config.get('views', []): + if view.get('id') != view_id: + continue + cards = view.get('cards', []) + if data_format == FORMAT_YAML: + card_config = yaml_to_object(card_config) + if position is None: + cards.append(card_config) + else: + cards.insert(position, card_config) + save_yaml(fname, config) + return + + raise ViewNotFoundError( + "View with ID: {} was not found in {}.".format(view_id, fname)) + async def async_setup(hass, config): """Set up the Lovelace commands.""" @@ -26,6 +247,18 @@ async def async_setup(hass, config): WS_TYPE_GET_LOVELACE_UI, websocket_lovelace_config, SCHEMA_GET_LOVELACE_UI) + hass.components.websocket_api.async_register_command( + WS_TYPE_GET_CARD, websocket_lovelace_get_card, + SCHEMA_GET_CARD) + + hass.components.websocket_api.async_register_command( + WS_TYPE_UPDATE_CARD, websocket_lovelace_update_card, + SCHEMA_UPDATE_CARD) + + hass.components.websocket_api.async_register_command( + WS_TYPE_ADD_CARD, websocket_lovelace_add_card, + SCHEMA_ADD_CARD) + return True @@ -35,13 +268,15 @@ async def websocket_lovelace_config(hass, connection, msg): error = None try: config = await hass.async_add_executor_job( - load_yaml, hass.config.path('ui-lovelace.yaml')) + load_config, hass.config.path(LOVELACE_CONFIG_FILE)) message = websocket_api.result_message( msg['id'], config ) except FileNotFoundError: error = ('file_not_found', 'Could not find ui-lovelace.yaml in your config dir.') + except UnsupportedYamlError as err: + error = 'unsupported_error', str(err) except HomeAssistantError as err: error = 'load_error', str(err) @@ -49,3 +284,85 @@ async def websocket_lovelace_config(hass, connection, msg): message = websocket_api.error_message(msg['id'], *error) connection.send_message(message) + + +@websocket_api.async_response +async def websocket_lovelace_get_card(hass, connection, msg): + """Send lovelace card config over websocket config.""" + error = None + try: + card = await hass.async_add_executor_job( + get_card, hass.config.path(LOVELACE_CONFIG_FILE), msg['card_id'], + msg.get('format', FORMAT_YAML)) + message = websocket_api.result_message( + msg['id'], card + ) + except FileNotFoundError: + error = ('file_not_found', + 'Could not find ui-lovelace.yaml in your config dir.') + except UnsupportedYamlError as err: + error = 'unsupported_error', str(err) + except CardNotFoundError as err: + error = 'card_not_found', str(err) + except HomeAssistantError as err: + error = 'load_error', str(err) + + if error is not None: + message = websocket_api.error_message(msg['id'], *error) + + connection.send_message(message) + + +@websocket_api.async_response +async def websocket_lovelace_update_card(hass, connection, msg): + """Receive lovelace card config over websocket and save.""" + error = None + try: + await hass.async_add_executor_job( + update_card, hass.config.path(LOVELACE_CONFIG_FILE), + msg['card_id'], msg['card_config'], msg.get('format', FORMAT_YAML)) + message = websocket_api.result_message( + msg['id'], True + ) + except FileNotFoundError: + error = ('file_not_found', + 'Could not find ui-lovelace.yaml in your config dir.') + except UnsupportedYamlError as err: + error = 'unsupported_error', str(err) + except CardNotFoundError as err: + error = 'card_not_found', str(err) + except HomeAssistantError as err: + error = 'save_error', str(err) + + if error is not None: + message = websocket_api.error_message(msg['id'], *error) + + connection.send_message(message) + + +@websocket_api.async_response +async def websocket_lovelace_add_card(hass, connection, msg): + """Add new card to view over websocket and save.""" + error = None + try: + await hass.async_add_executor_job( + add_card, hass.config.path(LOVELACE_CONFIG_FILE), + msg['view_id'], msg['card_config'], msg.get('position'), + msg.get('format', FORMAT_YAML)) + message = websocket_api.result_message( + msg['id'], True + ) + except FileNotFoundError: + error = ('file_not_found', + 'Could not find ui-lovelace.yaml in your config dir.') + except UnsupportedYamlError as err: + error = 'unsupported_error', str(err) + except ViewNotFoundError as err: + error = 'view_not_found', str(err) + except HomeAssistantError as err: + error = 'save_error', str(err) + + if error is not None: + message = websocket_api.error_message(msg['id'], *error) + + connection.send_message(message) diff --git a/homeassistant/components/mailgun/.translations/ca.json b/homeassistant/components/mailgun/.translations/ca.json new file mode 100644 index 00000000000..d644b9b8c73 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/ca.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "La vostra inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de Mailgun.", + "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." + }, + "create_entry": { + "default": "Per enviar esdeveniments a Home Assistant, haureu de configurar [Webhooks amb Mailgun] ({mailgun_url}). \n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - M\u00e8tode: POST \n - Tipus de contingut: application/x-www-form-urlencoded\n\nConsulteu [la documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar les dades entrants." + }, + "step": { + "user": { + "description": "Esteu segur que voleu configurar Mailgun?", + "title": "Configureu el Webhook de Mailgun" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/en.json b/homeassistant/components/mailgun/.translations/en.json new file mode 100644 index 00000000000..3abb8aba726 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Mailgun messages.", + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + }, + "step": { + "user": { + "description": "Are you sure you want to set up Mailgun?", + "title": "Set up the Mailgun Webhook" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/ko.json b/homeassistant/components/mailgun/.translations/ko.json new file mode 100644 index 00000000000..0dd8cbdb47d --- /dev/null +++ b/homeassistant/components/mailgun/.translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Mailgun \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c\ud569\ub2c8\ub2e4.", + "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." + }, + "create_entry": { + "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Mailgun Webhook]({mailgun_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/x-www-form-urlencoded\n \n Home Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." + }, + "step": { + "user": { + "description": "Mailgun \uc744 \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Mailgun Webhook \uc124\uc815" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/lb.json b/homeassistant/components/mailgun/.translations/lb.json new file mode 100644 index 00000000000..f84225444d9 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/lb.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u00c4r Home Assistant Instanz muss iwwert Internet accessibel si fir Mailgun Noriichten z'empf\u00e4nken.", + "one_instance_allowed": "N\u00ebmmen eng eenzeg Instanz ass n\u00e9ideg." + }, + "create_entry": { + "default": "Fir Evenementer un Home Assistant ze sch\u00e9cken, mussen [Webhooks mat Mailgun]({mailgun_url}) ageriicht ginn.\n\nF\u00ebllt folgend Informatiounen aus:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nLiest [Dokumentatioun]({docs_url}) w\u00e9i een Automatiounen ariicht welch eingehend Donn\u00e9\u00eb trait\u00e9ieren." + }, + "step": { + "user": { + "description": "S\u00e9cher fir Mailgun anzeriichten?", + "title": "Mailgun Webhook ariichten" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/nl.json b/homeassistant/components/mailgun/.translations/nl.json new file mode 100644 index 00000000000..d71c311b7f8 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Uw Home Assistant instantie moet toegankelijk zijn vanaf het internet om Mailgun-berichten te ontvangen.", + "one_instance_allowed": "Slechts \u00e9\u00e9n enkele instantie is nodig." + }, + "step": { + "user": { + "description": "Weet u zeker dat u Mailgun wilt instellen?", + "title": "Stel de Mailgun Webhook in" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/pl.json b/homeassistant/components/mailgun/.translations/pl.json new file mode 100644 index 00000000000..ba89efab0c2 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/pl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Tw\u00f3j Home Assistant musi by\u0107 dost\u0119pny z Internetu, aby odbiera\u0107 komunikaty Mailgun.", + "one_instance_allowed": "Wymagana jest tylko jedna instancja." + }, + "create_entry": { + "default": "Aby wysy\u0142a\u0107 zdarzenia do Home Assistant'a, musisz skonfigurowa\u0107 [Mailgun Webhook]({mailgun_url}). \n\n Wprowad\u017a nast\u0119puj\u0105ce dane:\n\n - URL: `{webhook_url}` \n - Metoda: POST \n - Typ zawarto\u015bci: application/x-www-form-urlencoded \n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}) na temat konfiguracji automatyzacji by obs\u0142u\u017cy\u0107 przychodz\u0105ce dane." + }, + "step": { + "user": { + "description": "Czy chcesz skonfigurowa\u0107 Mailgun?", + "title": "Konfiguracja Mailgun Webhook" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/pt.json b/homeassistant/components/mailgun/.translations/pt.json new file mode 100644 index 00000000000..963d3322d84 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/pt.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "A sua inst\u00e2ncia Home Assistent precisa de ser acess\u00edvel a partir da internet para receber mensagens Mailgun.", + "one_instance_allowed": "Apenas uma \u00fanica inst\u00e2ncia \u00e9 necess\u00e1ria." + }, + "create_entry": { + "default": "Para enviar eventos para o Home Assistant, voc\u00ea precisar\u00e1 configurar [Webhooks with Mailgun] ({mailgun_url}). \n\n Preencha as seguintes informa\u00e7\u00f5es: \n\n - URL: `{webhook_url}`\n - M\u00e9todo: POST \n - Tipo de Conte\u00fado: application/x-www-form-urlencoded \n\n Veja [a documenta\u00e7\u00e3o] ({docs_url}) sobre como configurar automa\u00e7\u00f5es para manipular dados de entrada." + }, + "step": { + "user": { + "description": "Tem certeza de que deseja configurar o Mailgun?", + "title": "Configurar o Mailgun Webhook" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/ru.json b/homeassistant/components/mailgun/.translations/ru.json new file mode 100644 index 00000000000..62007a95809 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/ru.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u0412\u0430\u0448 Home Assistant \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u0437 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0430 \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 Mailgun.", + "one_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "create_entry": { + "default": "\u0414\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0432 Home Assistant \u0432\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c [Webhooks \u0434\u043b\u044f Mailgun]({mailgun_url}).\n\n\u0417\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({docs_url}) \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0437\u0430\u0446\u0438\u0439 \u043f\u043e \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0435 \u043f\u043e\u0441\u0442\u0443\u043f\u0430\u044e\u0449\u0438\u0445 \u0434\u0430\u043d\u043d\u044b\u0445." + }, + "step": { + "user": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Mailgun?", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Mailgun Webhook" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/sl.json b/homeassistant/components/mailgun/.translations/sl.json new file mode 100644 index 00000000000..12dad4d8c7e --- /dev/null +++ b/homeassistant/components/mailgun/.translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": " \u010ce \u017eelite prejemati sporo\u010dila Mailgun, mora biti Home Assistent dostopen prek interneta.", + "one_instance_allowed": "Potrebna je samo ena instanca." + }, + "create_entry": { + "default": "Za po\u0161iljanje dogodkov Home Assistentu boste morali nastaviti [Webhooks z Mailgun]({mailgun_url}).\n\nIzpolnite naslednje informacije:\n\n- URL: `{webhook_url}`\n- Metoda: POST\n- Vrsta vsebine: application/x-www-form-urlencoded\n\nGlej [dokumentacijo]({docs_url}) o tem, kako nastavite automations za obravnavo dohodnih podatkov." + }, + "step": { + "user": { + "description": "Ali ste prepri\u010dani, da \u017eelite nastaviti Mailgun?", + "title": "Nastavite Mailgun Webhook" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/zh-Hant.json b/homeassistant/components/mailgun/.translations/zh-Hant.json new file mode 100644 index 00000000000..4b9ab3a7abb --- /dev/null +++ b/homeassistant/components/mailgun/.translations/zh-Hant.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant \u5be6\u4f8b\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 Mailgun \u8a0a\u606f\u3002", + "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002" + }, + "create_entry": { + "default": "\u6b32\u50b3\u9001\u4e8b\u4ef6\u81f3 Home Assistant\uff0c\u5c07\u9700\u8a2d\u5b9a [Webhooks with Mailgun]({mailgun_url}) \u3002\n\n\u8acb\u586b\u5beb\u4e0b\u5217\u8cc7\u8a0a\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\n\u95dc\u65bc\u5982\u4f55\u50b3\u5165\u8cc7\u6599\u81ea\u52d5\u5316\u8a2d\u5b9a\uff0c\u8acb\u53c3\u95b1[\u6587\u4ef6]({docs_url})\u4ee5\u9032\u884c\u4e86\u89e3\u3002" + }, + "step": { + "user": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Mailgun\uff1f", + "title": "\u8a2d\u5b9a Mailgun Webhook" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index ffcb0f6ab95..401fd729f9c 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2018.09.26'] +REQUIREMENTS = ['youtube_dl==2018.10.05'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index 296548dd3c2..bf934311303 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -21,7 +21,7 @@ from homeassistant.const import ( STATE_PAUSED, STATE_PLAYING) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.7.5'] +REQUIREMENTS = ['denonavr==0.7.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/dlna_dmr.py b/homeassistant/components/media_player/dlna_dmr.py index b787ed689c8..bf3fce97650 100644 --- a/homeassistant/components/media_player/dlna_dmr.py +++ b/homeassistant/components/media_player/dlna_dmr.py @@ -25,7 +25,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.util import get_local_ip -REQUIREMENTS = ['async-upnp-client==0.12.6'] +REQUIREMENTS = ['async-upnp-client==0.12.7'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/gstreamer.py b/homeassistant/components/media_player/gstreamer.py index e520fcb1033..fa8545bae03 100644 --- a/homeassistant/components/media_player/gstreamer.py +++ b/homeassistant/components/media_player/gstreamer.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP, STATE_IDLE import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['gstreamer-player==1.1.0'] +REQUIREMENTS = ['gstreamer-player==1.1.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/horizon.py b/homeassistant/components/media_player/horizon.py index 04471c69b9c..058796ea46d 100644 --- a/homeassistant/components/media_player/horizon.py +++ b/homeassistant/components/media_player/horizon.py @@ -92,9 +92,12 @@ class HorizonDevice(MediaPlayerDevice): @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) def update(self): """Update State using the media server running on the Horizon.""" - if self._client.is_powered_on(): - self._state = STATE_PLAYING - else: + try: + if self._client.is_powered_on(): + self._state = STATE_PLAYING + else: + self._state = STATE_OFF + except OSError: self._state = STATE_OFF def turn_on(self): diff --git a/homeassistant/components/media_player/lg_soundbar.py b/homeassistant/components/media_player/lg_soundbar.py new file mode 100644 index 00000000000..38b27bd074a --- /dev/null +++ b/homeassistant/components/media_player/lg_soundbar.py @@ -0,0 +1,196 @@ +""" +Support for LG soundbars. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.lg_soundbar/ +""" +import logging + +from homeassistant.components.media_player import ( + SUPPORT_SELECT_SOURCE, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, + SUPPORT_SELECT_SOUND_MODE, MediaPlayerDevice) + +from homeassistant.const import STATE_ON + +REQUIREMENTS = ['temescal==0.1'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_LG = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | SUPPORT_SELECT_SOURCE \ + | SUPPORT_SELECT_SOUND_MODE + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the LG platform.""" + if discovery_info is not None: + add_entities([LGDevice(discovery_info)], True) + + +class LGDevice(MediaPlayerDevice): + """Representation of an LG soundbar device.""" + + def __init__(self, discovery_info): + """Initialize the LG speakers.""" + import temescal + + host = discovery_info.get('host') + port = discovery_info.get('port') + + self._name = "" + self._volume = 0 + self._volume_min = 0 + self._volume_max = 0 + self._function = -1 + self._functions = [] + self._equaliser = -1 + self._equalisers = [] + self._mute = 0 + self._rear_volume = 0 + self._rear_volume_min = 0 + self._rear_volume_max = 0 + self._woofer_volume = 0 + self._woofer_volume_min = 0 + self._woofer_volume_max = 0 + self._bass = 0 + self._treble = 0 + + self._device = temescal.temescal(host, port=port, + callback=self.handle_event) + self.update() + + def handle_event(self, response): + """Handle responses from the speakers.""" + data = response['data'] + if response['msg'] == "EQ_VIEW_INFO": + if 'i_bass' in data: + self._bass = data['i_bass'] + if 'i_treble' in data: + self._treble = data['i_treble'] + if 'ai_eq_list' in data: + self._equalisers = data['ai_eq_list'] + if 'i_curr_eq' in data: + self._equaliser = data['i_curr_eq'] + elif response['msg'] == "SPK_LIST_VIEW_INFO": + if 'i_vol' in data: + self._volume = data['i_vol'] + if 's_user_name' in data: + self._name = data['s_user_name'] + if 'i_vol_min' in data: + self._volume_min = data['i_vol_min'] + if 'i_vol_max' in data: + self._volume_max = data['i_vol_max'] + if 'b_mute' in data: + self._mute = data['b_mute'] + if 'i_curr_func' in data: + self._function = data['i_curr_func'] + elif response['msg'] == "FUNC_VIEW_INFO": + if 'i_curr_func' in data: + self._function = data['i_curr_func'] + if 'ai_func_list' in data: + self._functions = data['ai_func_list'] + elif response['msg'] == "SETTING_VIEW_INFO": + if 'i_rear_min' in data: + self._rear_volume_min = data['i_rear_min'] + if 'i_rear_max' in data: + self._rear_volume_max = data['i_rear_max'] + if 'i_rear_level' in data: + self._rear_volume = data['i_rear_level'] + if 'i_woofer_min' in data: + self._woofer_volume_min = data['i_woofer_min'] + if 'i_woofer_max' in data: + self._woofer_volume_max = data['i_woofer_max'] + if 'i_woofer_level' in data: + self._woofer_volume = data['i_woofer_level'] + if 'i_curr_eq' in data: + self._equaliser = data['i_curr_eq'] + if 's_user_name' in data: + self._name = data['s_user_name'] + self.schedule_update_ha_state() + + def update(self): + """Trigger updates from the device.""" + self._device.get_eq() + self._device.get_info() + self._device.get_func() + self._device.get_settings() + self._device.get_product_info() + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + if self._volume_max != 0: + return self._volume/self._volume_max + return 0 + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._mute + + @property + def state(self): + """Return the state of the device.""" + return STATE_ON + + @property + def sound_mode(self): + """Return the current sound mode.""" + import temescal + if self._equaliser == -1: + return "" + return temescal.equalisers[self._equaliser] + + @property + def sound_mode_list(self): + """Return the available sound modes.""" + import temescal + modes = [] + for equaliser in self._equalisers: + modes.append(temescal.equalisers[equaliser]) + return sorted(modes) + + @property + def source(self): + """Return the current input source.""" + import temescal + if self._function == -1: + return "" + return temescal.functions[self._function] + + @property + def source_list(self): + """List of available input sources.""" + import temescal + sources = [] + for function in self._functions: + sources.append(temescal.functions[function]) + return sorted(sources) + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_LG + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + volume = volume * self._volume_max + self._device.set_volume(int(volume)) + + def mute_volume(self, mute): + """Mute (true) or unmute (false) media player.""" + self._device.set_mute(mute) + + def select_source(self, source): + """Select input source.""" + import temescal + self._device.set_func(temescal.functions.index(source)) + + def select_sound_mode(self, sound_mode): + """Set Sound Mode for Receiver..""" + import temescal + self._device.set_eq(temescal.equalisers.index(sound_mode)) diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 8953215f44e..b5eecd3d403 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -79,7 +79,7 @@ class MpdDevice(MediaPlayerDevice): # set up MPD client self._client = mpd.MPDClient() - self._client.timeout = 5 + self._client.timeout = 30 self._client.idletimeout = None def _connect(self): diff --git a/homeassistant/components/media_player/samsungtv.py b/homeassistant/components/media_player/samsungtv.py index 3a66aa66dc0..45d158a8653 100644 --- a/homeassistant/components/media_player/samsungtv.py +++ b/homeassistant/components/media_player/samsungtv.py @@ -8,8 +8,6 @@ import asyncio from datetime import timedelta import logging import socket -import subprocess -import sys import voluptuous as vol @@ -19,8 +17,7 @@ from homeassistant.components.media_player import ( SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, MediaPlayerDevice) from homeassistant.const import ( - CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TIMEOUT, STATE_OFF, - STATE_ON, STATE_UNKNOWN) + CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TIMEOUT, STATE_OFF) import homeassistant.helpers.config_validation as cv from homeassistant.util import dt as dt_util @@ -102,7 +99,7 @@ class SamsungTVDevice(MediaPlayerDevice): self._muted = False # Assume that the TV is in Play mode self._playing = True - self._state = STATE_UNKNOWN + self._state = None self._remote = None # Mark the end of a shutdown command (need to wait 15 seconds before # sending the next command to avoid turning the TV back ON). @@ -124,24 +121,7 @@ class SamsungTVDevice(MediaPlayerDevice): def update(self): """Update state of device.""" - if sys.platform == 'win32': - timeout_arg = '-w {}000'.format(self._config['timeout']) - _ping_cmd = [ - 'ping', '-n 3', timeout_arg, self._config['host']] - else: - timeout_arg = '-W{}'.format(self._config['timeout']) - _ping_cmd = [ - 'ping', '-n', '-q', - '-c3', timeout_arg, self._config['host']] - - ping = subprocess.Popen( - _ping_cmd, - stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) - try: - ping.communicate() - self._state = STATE_ON if ping.returncode == 0 else STATE_OFF - except subprocess.CalledProcessError: - self._state = STATE_OFF + self.send_key("KEY") def get_remote(self): """Create or return a remote control instance.""" @@ -168,11 +148,11 @@ class SamsungTVDevice(MediaPlayerDevice): BrokenPipeError): # BrokenPipe can occur when the commands is sent to fast self._remote = None - self._state = STATE_ON + self._state = None except (self._exceptions_class.UnhandledResponse, self._exceptions_class.AccessDenied): # We got a response so it's on. - self._state = STATE_ON + self._state = None self._remote = None _LOGGER.debug("Failed sending command %s", key, exc_info=True) return diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 41ca1b4e85e..28ff269f400 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -447,16 +447,15 @@ class SonosDevice(MediaPlayerDevice): """Set available favorites.""" # SoCo 0.16 raises a generic Exception on invalid xml in favorites. # Filter those out now so our list is safe to use. - # pylint: disable=broad-except try: self._favorites = [] for fav in self.soco.music_library.get_sonos_favorites(): try: if fav.reference.get_uri(): self._favorites.append(fav) - except Exception: + except Exception: # pylint: disable=broad-except _LOGGER.debug("Ignoring invalid favorite '%s'", fav.title) - except Exception: + except Exception: # pylint: disable=broad-except _LOGGER.debug("Ignoring invalid favorite list") def _radio_artwork(self, url): diff --git a/homeassistant/components/media_player/volumio.py b/homeassistant/components/media_player/volumio.py index de0f726c2ce..373d3c380fc 100644 --- a/homeassistant/components/media_player/volumio.py +++ b/homeassistant/components/media_player/volumio.py @@ -90,7 +90,6 @@ class Volumio(MediaPlayerDevice): self._url = '{}:{}'.format(host, str(port)) self._name = name self._state = {} - self.async_update() self._lastvol = self._state.get('volume', 0) self._playlists = [] self._currentplaylist = None @@ -113,7 +112,9 @@ class Volumio(MediaPlayerDevice): return False except (asyncio.TimeoutError, aiohttp.ClientError) as error: - _LOGGER.error("Failed communicating with Volumio: %s", type(error)) + _LOGGER.error( + "Failed communicating with Volumio '%s': %s", + self._name, type(error)) return False try: diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index 0a5b9fe509b..946e0517435 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -24,7 +24,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.script import Script -REQUIREMENTS = ['pylgtv==0.1.7', 'websockets==6.0'] +REQUIREMENTS = ['pylgtv==0.1.9', 'websockets==6.0'] _CONFIGURING = {} # type: Dict[str, str] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/yamaha.py b/homeassistant/components/media_player/yamaha.py index be61560d52b..101dfc2bc53 100644 --- a/homeassistant/components/media_player/yamaha.py +++ b/homeassistant/components/media_player/yamaha.py @@ -12,9 +12,10 @@ import voluptuous as vol from homeassistant.components.media_player import ( DOMAIN, MEDIA_PLAYER_SCHEMA, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, - SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOUND_MODE, SUPPORT_SELECT_SOURCE, - SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, - SUPPORT_VOLUME_SET, MediaPlayerDevice) + SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_STOP, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, + SUPPORT_SELECT_SOUND_MODE, MediaPlayerDevice) + from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, STATE_IDLE, STATE_OFF, STATE_ON, STATE_PLAYING) @@ -184,8 +185,12 @@ class YamahaDevice(MediaPlayerDevice): self._playback_support = self.receiver.get_playback_support() self._is_playback_supported = self.receiver.is_playback_supported( self._current_source) - self._sound_mode = self.receiver.surround_program - self._sound_mode_list = self.receiver.surround_programs() + if self._zone == "Main_Zone": + self._sound_mode = self.receiver.surround_program + self._sound_mode_list = self.receiver.surround_programs() + else: + self._sound_mode = None + self._sound_mode_list = None def build_source_list(self): """Build the source list.""" diff --git a/homeassistant/components/media_player/yamaha_musiccast.py b/homeassistant/components/media_player/yamaha_musiccast.py index bf21a3f5028..535a2ad01ca 100644 --- a/homeassistant/components/media_player/yamaha_musiccast.py +++ b/homeassistant/components/media_player/yamaha_musiccast.py @@ -39,7 +39,7 @@ DEFAULT_INTERVAL = 480 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.positive_int, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(INTERVAL_SECONDS, default=DEFAULT_INTERVAL): cv.positive_int, }) diff --git a/homeassistant/components/modbus.py b/homeassistant/components/modbus.py index f484cb31a6c..c8d71af71b4 100644 --- a/homeassistant/components/modbus.py +++ b/homeassistant/components/modbus.py @@ -37,7 +37,7 @@ SERIAL_SCHEMA = { ETHERNET_SCHEMA = { vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PORT): cv.positive_int, + vol.Required(CONF_PORT): cv.port, vol.Required(CONF_TYPE): vol.Any('tcp', 'udp', 'rtuovertcp'), vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, } diff --git a/homeassistant/components/mqtt/.translations/es.json b/homeassistant/components/mqtt/.translations/es.json index e9e869ae966..182cce86057 100644 --- a/homeassistant/components/mqtt/.translations/es.json +++ b/homeassistant/components/mqtt/.translations/es.json @@ -2,6 +2,10 @@ "config": { "step": { "hassio_confirm": { + "data": { + "discovery": "Habilitar descubrimiento" + }, + "description": "\u00bfDesea configurar Home Assistant para conectarse al agente MQTT provisto por el complemento hass.io {addon} ?", "title": "MQTT Broker a trav\u00e9s del complemento Hass.io" } } diff --git a/homeassistant/components/mqtt/.translations/hu.json b/homeassistant/components/mqtt/.translations/hu.json index ba08d36d581..f08c601633e 100644 --- a/homeassistant/components/mqtt/.translations/hu.json +++ b/homeassistant/components/mqtt/.translations/hu.json @@ -10,6 +10,7 @@ "broker": { "data": { "broker": "Br\u00f3ker", + "discovery": "Felfedez\u00e9s enged\u00e9lyez\u00e9se", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" diff --git a/homeassistant/components/mqtt/.translations/pt.json b/homeassistant/components/mqtt/.translations/pt.json index 1b8c3946b7c..21b9cbdf755 100644 --- a/homeassistant/components/mqtt/.translations/pt.json +++ b/homeassistant/components/mqtt/.translations/pt.json @@ -9,16 +9,23 @@ "step": { "broker": { "data": { - "broker": "", + "broker": "Broker", "discovery": "Ativar descoberta", "password": "Palavra-passe", "port": "Porto", "username": "Utilizador" }, "description": "Por favor, insira os detalhes de liga\u00e7\u00e3o ao seu broker MQTT.", - "title": "" + "title": "MQTT" + }, + "hassio_confirm": { + "data": { + "discovery": "Ativar descoberta" + }, + "description": "Deseja configurar o Home Assistant para se ligar ao broker MQTT fornecido pelo add-on hass.io {addon}?", + "title": "MQTT Broker atrav\u00e9s do add-on Hass.io" } }, - "title": "" + "title": "MQTT" } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/ro.json b/homeassistant/components/mqtt/.translations/ro.json new file mode 100644 index 00000000000..bcd150e3063 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/ro.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Este permis\u0103 numai o singur\u0103 configura\u021bie de MQTT." + }, + "error": { + "cannot_connect": "Imposibil de conectat la broker." + }, + "step": { + "broker": { + "data": { + "broker": "Broker", + "discovery": "Activa\u021bi descoperirea", + "password": "Parol\u0103", + "port": "Port", + "username": "Nume de utilizator" + }, + "description": "Introduce\u021bi informa\u021biile de conectare ale brokerului dvs. MQTT.", + "title": "MQTT" + }, + "hassio_confirm": { + "data": { + "discovery": "Activa\u021bi descoperirea" + }, + "description": "Dori\u021bi s\u0103 configura\u021bi Home Assistant pentru a v\u0103 conecta la brokerul MQTT furnizat de addon-ul {addon} ?", + "title": "MQTT Broker, prin intermediul Hass.io add-on" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/tr.json b/homeassistant/components/mqtt/.translations/tr.json new file mode 100644 index 00000000000..1b73b94d5a4 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/tr.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "hassio_confirm": { + "data": { + "discovery": "Ke\u015ffetmeyi etkinle\u015ftir" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 3e25563e9ba..2f1895019dd 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -21,7 +21,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( CONF_PASSWORD, CONF_PAYLOAD, CONF_PORT, CONF_PROTOCOL, CONF_USERNAME, - CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_STOP) + CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_STOP, CONF_NAME) from homeassistant.core import Event, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -73,6 +73,12 @@ CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' CONF_QOS = 'qos' CONF_RETAIN = 'retain' +CONF_IDENTIFIERS = 'identifiers' +CONF_CONNECTIONS = 'connections' +CONF_MANUFACTURER = 'manufacturer' +CONF_MODEL = 'model' +CONF_SW_VERSION = 'sw_version' + PROTOCOL_31 = '3.1' PROTOCOL_311 = '3.1.1' @@ -144,6 +150,15 @@ def valid_publish_topic(value: Any) -> str: return value +def validate_device_has_at_least_one_identifier(value: ConfigType) -> \ + ConfigType: + """Validate that a device info entry has at least one identifying value.""" + if not value.get(CONF_IDENTIFIERS) and not value.get(CONF_CONNECTIONS): + raise vol.Invalid("Device must have at least one identifying value in " + "'identifiers' and/or 'connections'") + return value + + _VALID_QOS_SCHEMA = vol.All(vol.Coerce(int), vol.In([0, 1, 2])) CLIENT_KEY_AUTH_MSG = 'client_key and client_cert must both be present in ' \ @@ -198,6 +213,17 @@ MQTT_AVAILABILITY_SCHEMA = vol.Schema({ default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string, }) +MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All(vol.Schema({ + vol.Optional(CONF_IDENTIFIERS, default=list): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_CONNECTIONS, default=list): + vol.All(cv.ensure_list, [vol.All(vol.Length(2), [cv.string])]), + vol.Optional(CONF_MANUFACTURER): cv.string, + vol.Optional(CONF_MODEL): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_SW_VERSION): cv.string, +}), validate_device_has_at_least_one_identifier) + MQTT_BASE_PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(SCHEMA_BASE) # Sensor type platforms subscribe to MQTT events @@ -866,3 +892,41 @@ class MqttDiscoveryUpdate(Entity): self.hass, MQTT_DISCOVERY_UPDATED.format(self._discovery_hash), discovery_callback) + + +class MqttEntityDeviceInfo(Entity): + """Mixin used for mqtt platforms that support the device registry.""" + + def __init__(self, device_config: Optional[ConfigType]) -> None: + """Initialize the device mixin.""" + self._device_config = device_config + + @property + def device_info(self): + """Return a device description for device registry.""" + if not self._device_config: + return None + + info = { + 'identifiers': { + (DOMAIN, id_) + for id_ in self._device_config[CONF_IDENTIFIERS] + }, + 'connections': { + tuple(x) for x in self._device_config[CONF_CONNECTIONS] + } + } + + if CONF_MANUFACTURER in self._device_config: + info['manufacturer'] = self._device_config[CONF_MANUFACTURER] + + if CONF_MODEL in self._device_config: + info['model'] = self._device_config[CONF_MODEL] + + if CONF_NAME in self._device_config: + info['name'] = self._device_config[CONF_NAME] + + if CONF_SW_VERSION in self._device_config: + info['sw_version'] = self._device_config[CONF_SW_VERSION] + + return info diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index a762978a330..b8c8627c038 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -49,6 +49,7 @@ CONFIG_ENTRY_PLATFORMS = { 'switch': ['mqtt'], 'climate': ['mqtt'], 'alarm_control_panel': ['mqtt'], + 'fan': ['mqtt'], } ALREADY_DISCOVERED = 'mqtt_discovered_components' @@ -57,6 +58,114 @@ CONFIG_ENTRY_IS_SETUP = 'mqtt_config_entry_is_setup' MQTT_DISCOVERY_UPDATED = 'mqtt_discovery_updated_{}' MQTT_DISCOVERY_NEW = 'mqtt_discovery_new_{}_{}' +TOPIC_BASE = '~' + +ABBREVIATIONS = { + 'aux_cmd_t': 'aux_command_topic', + 'aux_stat_tpl': 'aux_state_template', + 'aux_stat_t': 'aux_state_topic', + 'avty_t': 'availability_topic', + 'away_mode_cmd_t': 'away_mode_command_topic', + 'away_mode_stat_tpl': 'away_mode_state_template', + 'away_mode_stat_t': 'away_mode_state_topic', + 'bri_cmd_t': 'brightness_command_topic', + 'bri_scl': 'brightness_scale', + 'bri_stat_t': 'brightness_state_topic', + 'bri_val_tpl': 'brightness_value_template', + 'clr_temp_cmd_t': 'color_temp_command_topic', + 'clr_temp_stat_t': 'color_temp_state_topic', + 'clr_temp_val_tpl': 'color_temp_value_template', + 'cmd_t': 'command_topic', + 'curr_temp_t': 'current_temperature_topic', + 'dev_cla': 'device_class', + 'fx_cmd_t': 'effect_command_topic', + 'fx_list': 'effect_list', + 'fx_stat_t': 'effect_state_topic', + 'fx_val_tpl': 'effect_value_template', + 'exp_aft': 'expire_after', + 'fan_mode_cmd_t': 'fan_mode_command_topic', + 'fan_mode_stat_tpl': 'fan_mode_state_template', + 'fan_mode_stat_t': 'fan_mode_state_topic', + 'frc_upd': 'force_update', + 'hold_cmd_t': 'hold_command_topic', + 'hold_stat_tpl': 'hold_state_template', + 'hold_stat_t': 'hold_state_topic', + 'ic': 'icon', + 'init': 'initial', + 'json_attr': 'json_attributes', + 'max_temp': 'max_temp', + 'min_temp': 'min_temp', + 'mode_cmd_t': 'mode_command_topic', + 'mode_stat_tpl': 'mode_state_template', + 'mode_stat_t': 'mode_state_topic', + 'name': 'name', + 'on_cmd_type': 'on_command_type', + 'opt': 'optimistic', + 'osc_cmd_t': 'oscillation_command_topic', + 'osc_stat_t': 'oscillation_state_topic', + 'osc_val_tpl': 'oscillation_value_template', + 'pl_arm_away': 'payload_arm_away', + 'pl_arm_home': 'payload_arm_home', + 'pl_avail': 'payload_available', + 'pl_cls': 'payload_close', + 'pl_disarm': 'payload_disarm', + 'pl_hi_spd': 'payload_high_speed', + 'pl_lock': 'payload_lock', + 'pl_lo_spd': 'payload_low_speed', + 'pl_med_spd': 'payload_medium_speed', + 'pl_not_avail': 'payload_not_available', + 'pl_off': 'payload_off', + 'pl_on': 'payload_on', + 'pl_open': 'payload_open', + 'pl_osc_off': 'payload_oscillation_off', + 'pl_osc_on': 'payload_oscillation_on', + 'pl_stop': 'payload_stop', + 'pl_unlk': 'payload_unlock', + 'pow_cmd_t': 'power_command_topic', + 'ret': 'retain', + 'rgb_cmd_tpl': 'rgb_command_template', + 'rgb_cmd_t': 'rgb_command_topic', + 'rgb_stat_t': 'rgb_state_topic', + 'rgb_val_tpl': 'rgb_value_template', + 'send_if_off': 'send_if_off', + 'set_pos_tpl': 'set_position_template', + 'set_pos_t': 'set_position_topic', + 'spd_cmd_t': 'speed_command_topic', + 'spd_stat_t': 'speed_state_topic', + 'spd_val_tpl': 'speed_value_template', + 'spds': 'speeds', + 'stat_clsd': 'state_closed', + 'stat_off': 'state_off', + 'stat_on': 'state_on', + 'stat_open': 'state_open', + 'stat_t': 'state_topic', + 'stat_val_tpl': 'state_value_template', + 'swing_mode_cmd_t': 'swing_mode_command_topic', + 'swing_mode_stat_tpl': 'swing_mode_state_template', + 'swing_mode_stat_t': 'swing_mode_state_topic', + 'temp_cmd_t': 'temperature_command_topic', + 'temp_stat_tpl': 'temperature_state_template', + 'temp_stat_t': 'temperature_state_topic', + 'tilt_clsd_val': 'tilt_closed_value', + 'tilt_cmd_t': 'tilt_command_topic', + 'tilt_inv_stat': 'tilt_invert_state', + 'tilt_max': 'tilt_max', + 'tilt_min': 'tilt_min', + 'tilt_opnd_val': 'tilt_opened_value', + 'tilt_status_opt': 'tilt_status_optimistic', + 'tilt_status_t': 'tilt_status_topic', + 't': 'topic', + 'uniq_id': 'unique_id', + 'unit_of_meas': 'unit_of_measurement', + 'val_tpl': 'value_template', + 'whit_val_cmd_t': 'white_value_command_topic', + 'whit_val_stat_t': 'white_value_state_topic', + 'whit_val_tpl': 'white_value_template', + 'xy_cmd_t': 'xy_command_topic', + 'xy_stat_t': 'xy_state_topic', + 'xy_val_tpl': 'xy_value_template', +} + async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, config_entry=None) -> bool: @@ -74,6 +183,29 @@ async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, _LOGGER.warning("Component %s is not supported", component) return + if payload: + try: + payload = json.loads(payload) + except ValueError: + _LOGGER.warning("Unable to parse JSON %s: '%s'", + object_id, payload) + return + + payload = dict(payload) + + for key in list(payload.keys()): + abbreviated_key = key + key = ABBREVIATIONS.get(key, key) + payload[key] = payload.pop(abbreviated_key) + + if TOPIC_BASE in payload: + base = payload[TOPIC_BASE] + for key, value in payload.items(): + if value[0] == TOPIC_BASE and key.endswith('_topic'): + payload[key] = "{}{}".format(base, value[1:]) + if value[-1] == TOPIC_BASE and key.endswith('_topic'): + payload[key] = "{}{}".format(value[:-1], base) + # If present, the node_id will be included in the discovered object id discovery_id = '_'.join((node_id, object_id)) if node_id else object_id @@ -90,14 +222,6 @@ async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, hass, MQTT_DISCOVERY_UPDATED.format(discovery_hash), payload) elif payload: # Add component - try: - payload = json.loads(payload) - except ValueError: - _LOGGER.warning("Unable to parse JSON %s: '%s'", - object_id, payload) - return - - payload = dict(payload) platform = payload.get(CONF_PLATFORM, 'mqtt') if platform not in ALLOWED_PLATFORMS.get(component, []): _LOGGER.warning("Platform %s (component %s) is not allowed", diff --git a/homeassistant/components/nest/.translations/hu.json b/homeassistant/components/nest/.translations/hu.json index 142747a016f..aa99b46e576 100644 --- a/homeassistant/components/nest/.translations/hu.json +++ b/homeassistant/components/nest/.translations/hu.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_setup": "Csak egy Nest-fi\u00f3kot konfigur\u00e1lhat.", "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n." }, "error": { @@ -18,7 +19,8 @@ "link": { "data": { "code": "PIN-k\u00f3d" - } + }, + "title": "Nest fi\u00f3k \u00f6sszekapcsol\u00e1sa" } }, "title": "Nest" diff --git a/homeassistant/components/nest/.translations/ko.json b/homeassistant/components/nest/.translations/ko.json index 0caa70aeff2..a53a26bca5a 100644 --- a/homeassistant/components/nest/.translations/ko.json +++ b/homeassistant/components/nest/.translations/ko.json @@ -4,7 +4,7 @@ "already_setup": "\ud558\ub098\uc758 Nest \uacc4\uc815\ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", "authorize_url_fail": "\uc778\uc99d url \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "no_flows": "Nest \ub97c \uc778\uc99d\ud558\uae30 \uc804\uc5d0 Nest \ub97c \uad6c\uc131\ud574\uc57c \ud569\ub2c8\ub2e4. [\uc548\ub0b4](https://www.home-assistant.io/components/nest/)\ub97c \uc77d\uc5b4\ubcf4\uc138\uc694." + "no_flows": "Nest \ub97c \uc778\uc99d\ud558\uae30 \uc804\uc5d0 Nest \ub97c \uad6c\uc131\ud574\uc57c \ud569\ub2c8\ub2e4. [\uc548\ub0b4](https://www.home-assistant.io/components/nest/) \ub97c \uc77d\uc5b4\ubcf4\uc138\uc694." }, "error": { "internal_error": "\ucf54\ub4dc \uc720\ud6a8\uc131 \uac80\uc0ac\uc5d0 \ub0b4\ubd80 \uc624\ub958 \ubc1c\uc0dd", @@ -24,7 +24,7 @@ "data": { "code": "\ud540 \ucf54\ub4dc" }, - "description": "Nest \uacc4\uc815\uc744 \uc5f0\uacb0\ud558\ub824\uba74, [\uacc4\uc815 \uc5f0\uacb0 \uc2b9\uc778]({url})\uc744 \ud574\uc8fc\uc138\uc694.\n\n\uc2b9\uc778 \ud6c4, \uc544\ub798\uc758 \ud540 \ucf54\ub4dc\ub97c \ubcf5\uc0ac\ud558\uc5ec \ubd99\uc5ec\ub123\uc73c\uc138\uc694.", + "description": "Nest \uacc4\uc815\uc744 \uc5f0\uacb0\ud558\ub824\uba74, [\uacc4\uc815 \uc5f0\uacb0 \uc2b9\uc778]({url}) \uc744 \ud574\uc8fc\uc138\uc694.\n\n\uc2b9\uc778 \ud6c4, \uc544\ub798\uc758 \ud540 \ucf54\ub4dc\ub97c \ubcf5\uc0ac\ud558\uc5ec \ubd99\uc5ec\ub123\uc73c\uc138\uc694.", "title": "Nest \uacc4\uc815 \uc5f0\uacb0" } }, diff --git a/homeassistant/components/nest/.translations/pt.json b/homeassistant/components/nest/.translations/pt.json index 40743fe3ddb..5ea970d9fb3 100644 --- a/homeassistant/components/nest/.translations/pt.json +++ b/homeassistant/components/nest/.translations/pt.json @@ -28,6 +28,6 @@ "title": "Associar conta Nest" } }, - "title": "" + "title": "Nest" } } \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/ro.json b/homeassistant/components/nest/.translations/ro.json new file mode 100644 index 00000000000..f315cf549fb --- /dev/null +++ b/homeassistant/components/nest/.translations/ro.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "link": { + "data": { + "code": "Cod PIN" + }, + "title": "Leg\u0103tur\u0103 cont Nest" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/notify/aws_lambda.py b/homeassistant/components/notify/aws_lambda.py index 8a3cb900f4b..28fedf6434d 100644 --- a/homeassistant/components/notify/aws_lambda.py +++ b/homeassistant/components/notify/aws_lambda.py @@ -17,7 +17,7 @@ from homeassistant.components.notify import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import JSONEncoder -REQUIREMENTS = ['boto3==1.4.7'] +REQUIREMENTS = ['boto3==1.9.16'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/aws_sns.py b/homeassistant/components/notify/aws_sns.py index 7ecf5a7cc7f..065898bcb85 100644 --- a/homeassistant/components/notify/aws_sns.py +++ b/homeassistant/components/notify/aws_sns.py @@ -17,7 +17,7 @@ from homeassistant.components.notify import ( import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ["boto3==1.4.7"] +REQUIREMENTS = ["boto3==1.9.16"] CONF_REGION = 'region_name' CONF_ACCESS_KEY_ID = 'aws_access_key_id' diff --git a/homeassistant/components/notify/aws_sqs.py b/homeassistant/components/notify/aws_sqs.py index 30b673846e7..78e71bde97a 100644 --- a/homeassistant/components/notify/aws_sqs.py +++ b/homeassistant/components/notify/aws_sqs.py @@ -16,7 +16,7 @@ from homeassistant.components.notify import ( import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ["boto3==1.4.7"] +REQUIREMENTS = ["boto3==1.9.16"] CONF_REGION = 'region_name' CONF_ACCESS_KEY_ID = 'aws_access_key_id' diff --git a/homeassistant/components/notify/clicksend.py b/homeassistant/components/notify/clicksend.py index c028da2c579..5506d6ed6d0 100644 --- a/homeassistant/components/notify/clicksend.py +++ b/homeassistant/components/notify/clicksend.py @@ -22,6 +22,8 @@ _LOGGER = logging.getLogger(__name__) BASE_API_URL = 'https://rest.clicksend.com/v3' +DEFAULT_SENDER = 'hass' + HEADERS = {CONTENT_TYPE: CONTENT_TYPE_JSON} @@ -29,7 +31,7 @@ def validate_sender(config): """Set the optional sender name if sender name is not provided.""" if CONF_SENDER in config: return config - config[CONF_SENDER] = config[CONF_RECIPIENT] + config[CONF_SENDER] = DEFAULT_SENDER return config @@ -61,7 +63,7 @@ class ClicksendNotificationService(BaseNotificationService): self.username = config.get(CONF_USERNAME) self.api_key = config.get(CONF_API_KEY) self.recipients = config.get(CONF_RECIPIENT) - self.sender = config.get(CONF_SENDER, CONF_RECIPIENT) + self.sender = config.get(CONF_SENDER) def send_message(self, message="", **kwargs): """Send a message to a user.""" diff --git a/homeassistant/components/notify/homematic.py b/homeassistant/components/notify/homematic.py new file mode 100644 index 00000000000..2897123c690 --- /dev/null +++ b/homeassistant/components/notify/homematic.py @@ -0,0 +1,61 @@ +""" +Notification support for Homematic. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.homematic/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.notify import ( + BaseNotificationService, PLATFORM_SCHEMA, ATTR_DATA) +import homeassistant.helpers.config_validation as cv +from homeassistant.components.homematic import ( + DOMAIN, SERVICE_SET_DEVICE_VALUE, ATTR_ADDRESS, ATTR_CHANNEL, ATTR_PARAM, + ATTR_VALUE, ATTR_INTERFACE) +import homeassistant.helpers.template as template_helper + +_LOGGER = logging.getLogger(__name__) +DEPENDENCIES = ["homematic"] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(ATTR_ADDRESS): vol.All(cv.string, vol.Upper), + vol.Required(ATTR_CHANNEL): vol.Coerce(int), + vol.Required(ATTR_PARAM): vol.All(cv.string, vol.Upper), + vol.Required(ATTR_VALUE): cv.match_all, + vol.Optional(ATTR_INTERFACE): cv.string, +}) + + +def get_service(hass, config, discovery_info=None): + """Get the Homematic notification service.""" + data = { + ATTR_ADDRESS: config[ATTR_ADDRESS], + ATTR_CHANNEL: config[ATTR_CHANNEL], + ATTR_PARAM: config[ATTR_PARAM], + ATTR_VALUE: config[ATTR_VALUE] + } + if ATTR_INTERFACE in config: + data[ATTR_INTERFACE] = config[ATTR_INTERFACE] + + return HomematicNotificationService(hass, data) + + +class HomematicNotificationService(BaseNotificationService): + """Implement the notification service for Homematic.""" + + def __init__(self, hass, data): + """Initialize the service.""" + self.hass = hass + self.data = data + + def send_message(self, message="", **kwargs): + """Send a notification to the device.""" + data = {**self.data, **kwargs.get(ATTR_DATA, {})} + + if data.get(ATTR_VALUE) is not None: + templ = template_helper.Template(self.data[ATTR_VALUE], self.hass) + data[ATTR_VALUE] = template_helper.render_complex(templ, None) + + self.hass.services.call(DOMAIN, SERVICE_SET_DEVICE_VALUE, data) diff --git a/homeassistant/components/notify/twilio_call.py b/homeassistant/components/notify/twilio_call.py index b517808d2ce..538a4fd9512 100644 --- a/homeassistant/components/notify/twilio_call.py +++ b/homeassistant/components/notify/twilio_call.py @@ -42,7 +42,7 @@ class TwilioCallNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Call to specified target users.""" - from twilio import TwilioRestException + from twilio.base.exceptions import TwilioRestException targets = kwargs.get(ATTR_TARGET) diff --git a/homeassistant/components/notify/webostv.py b/homeassistant/components/notify/webostv.py index 78c43c5f0ad..92762b03aea 100644 --- a/homeassistant/components/notify/webostv.py +++ b/homeassistant/components/notify/webostv.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( ATTR_DATA, BaseNotificationService, PLATFORM_SCHEMA) from homeassistant.const import (CONF_FILENAME, CONF_HOST, CONF_ICON) -REQUIREMENTS = ['pylgtv==0.1.7'] +REQUIREMENTS = ['pylgtv==0.1.9'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index c5678dff351..1f4417e07b5 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -12,12 +12,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import ( - CONF_PASSWORD, CONF_SENDER, CONF_RECIPIENT, CONF_ROOM) + CONF_PASSWORD, CONF_SENDER, CONF_RECIPIENT, CONF_ROOM, CONF_RESOURCE) -REQUIREMENTS = ['sleekxmpp==1.3.2', - 'dnspython3==1.15.0', - 'pyasn1==0.3.7', - 'pyasn1-modules==0.1.5'] +REQUIREMENTS = ['slixmpp==1.4.0'] _LOGGER = logging.getLogger(__name__) @@ -31,84 +28,94 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_TLS, default=True): cv.boolean, vol.Optional(CONF_VERIFY, default=True): cv.boolean, vol.Optional(CONF_ROOM, default=''): cv.string, + vol.Optional(CONF_RESOURCE, default="home-assistant"): cv.string, }) -def get_service(hass, config, discovery_info=None): +async def async_get_service(hass, config, discovery_info=None): """Get the Jabber (XMPP) notification service.""" return XmppNotificationService( - config.get(CONF_SENDER), config.get(CONF_PASSWORD), - config.get(CONF_RECIPIENT), config.get(CONF_TLS), - config.get(CONF_VERIFY), config.get(CONF_ROOM)) + config.get(CONF_SENDER), config.get(CONF_RESOURCE), + config.get(CONF_PASSWORD), config.get(CONF_RECIPIENT), + config.get(CONF_TLS), config.get(CONF_VERIFY), + config.get(CONF_ROOM), hass.loop) class XmppNotificationService(BaseNotificationService): """Implement the notification service for Jabber (XMPP).""" - def __init__(self, sender, password, recipient, tls, verify, room): + def __init__(self, sender, resource, password, + recipient, tls, verify, room, loop): """Initialize the service.""" + self._loop = loop self._sender = sender + self._resource = resource self._password = password self._recipient = recipient self._tls = tls self._verify = verify self._room = room - def send_message(self, message="", **kwargs): + async def async_send_message(self, message="", **kwargs): """Send a message to a user.""" title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = '{}: {}'.format(title, message) if title else message - send_message('{}/home-assistant'.format(self._sender), - self._password, self._recipient, self._tls, - self._verify, self._room, data) + await async_send_message( + '{}/{}'.format(self._sender, self._resource), + self._password, self._recipient, self._tls, + self._verify, self._room, self._loop, data) -def send_message(sender, password, recipient, use_tls, - verify_certificate, room, message): +async def async_send_message(sender, password, recipient, use_tls, + verify_certificate, room, loop, message): """Send a message over XMPP.""" - import sleekxmpp + import slixmpp - class SendNotificationBot(sleekxmpp.ClientXMPP): + class SendNotificationBot(slixmpp.ClientXMPP): """Service for sending Jabber (XMPP) messages.""" def __init__(self): """Initialize the Jabber Bot.""" - super(SendNotificationBot, self).__init__(sender, password) + super().__init__(sender, password) - self.use_tls = use_tls + # need hass.loop!! + self.loop = loop + + self.force_starttls = use_tls self.use_ipv6 = False - self.add_event_handler('failed_auth', self.check_credentials) + self.add_event_handler( + 'failed_auth', self.disconnect_on_login_fail) self.add_event_handler('session_start', self.start) + if room: self.register_plugin('xep_0045') # MUC if not verify_certificate: self.add_event_handler('ssl_invalid_cert', self.discard_ssl_invalid_cert) - self.connect(use_tls=self.use_tls, use_ssl=False) - self.process() + self.connect(force_starttls=self.force_starttls, use_ssl=False) def start(self, event): """Start the communication and sends the message.""" - self.send_presence() self.get_roster() - + self.send_presence() if room: - _LOGGER.debug("Joining room %s.", room) - self.plugin['xep_0045'].joinMUC(room, sender, wait=True) + _LOGGER.debug("Joining room %s", room) + self.plugin['xep_0045'].join_muc(room, sender, wait=True) self.send_message(mto=room, mbody=message, mtype='groupchat') else: self.send_message(mto=recipient, mbody=message, mtype='chat') self.disconnect(wait=True) - def check_credentials(self, event): + def disconnect_on_login_fail(self, event): """Disconnect from the server if credentials are invalid.""" + _LOGGER.warning('Login failed') self.disconnect() @staticmethod def discard_ssl_invalid_cert(event): """Do nothing if ssl certificate is invalid.""" - _LOGGER.info('Ignoring invalid ssl certificate as requested.') + _LOGGER.info('Ignoring invalid ssl certificate as requested') SendNotificationBot() diff --git a/homeassistant/components/octoprint.py b/homeassistant/components/octoprint.py index ff52ad94d8b..2a39ac2c44a 100644 --- a/homeassistant/components/octoprint.py +++ b/homeassistant/components/octoprint.py @@ -11,43 +11,117 @@ import requests import voluptuous as vol from aiohttp.hdrs import CONTENT_TYPE -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONTENT_TYPE_JSON +from homeassistant.components.discovery import SERVICE_OCTOPRINT +from homeassistant.const import ( + CONF_API_KEY, CONF_HOST, CONTENT_TYPE_JSON, CONF_NAME, CONF_PORT, + CONF_SSL, TEMP_CELSIUS, CONF_MONITORED_CONDITIONS, CONF_SENSORS, + CONF_BINARY_SENSORS) +from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform +from homeassistant.util import slugify as util_slugify _LOGGER = logging.getLogger(__name__) DOMAIN = 'octoprint' CONF_NUMBER_OF_TOOLS = 'number_of_tools' CONF_BED = 'bed' +DEFAULT_NAME = 'OctoPrint' + + +def has_all_unique_names(value): + """Validate that printers have an unique name.""" + names = [util_slugify(printer['name']) for printer in value] + vol.Schema(vol.Unique())(names) + return value + + +BINARY_SENSOR_TYPES = { + # API Endpoint, Group, Key, unit + 'Printing': ['printer', 'state', 'printing', None], + 'Printing Error': ['printer', 'state', 'error', None] +} + +BINARY_SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(BINARY_SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + +SENSOR_TYPES = { + # API Endpoint, Group, Key, unit, icon + 'Temperatures': ['printer', 'temperature', '*', TEMP_CELSIUS], + 'Current State': ['printer', 'state', 'text', None, 'mdi:printer-3d'], + 'Job Percentage': ['job', 'progress', 'completion', '%', + 'mdi:file-percent'], + 'Time Remaining': ['job', 'progress', 'printTimeLeft', 'seconds', + 'mdi:clock-end'], + 'Time Elapsed': ['job', 'progress', 'printTime', 'seconds', + 'mdi:clock-start'], +} + +SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_PORT, default=80): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_NUMBER_OF_TOOLS, default=0): cv.positive_int, - vol.Optional(CONF_BED, default=False): cv.boolean - }), + vol.Optional(CONF_BED, default=False): cv.boolean, + vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, + vol.Optional(CONF_BINARY_SENSORS, default={}): BINARY_SENSOR_SCHEMA + })], has_all_unique_names), }, extra=vol.ALLOW_EXTRA) def setup(hass, config): """Set up the OctoPrint component.""" - base_url = 'http://{}/api/'.format(config[DOMAIN][CONF_HOST]) - api_key = config[DOMAIN][CONF_API_KEY] - number_of_tools = config[DOMAIN][CONF_NUMBER_OF_TOOLS] - bed = config[DOMAIN][CONF_BED] + printers = hass.data[DOMAIN] = {} + success = False - hass.data[DOMAIN] = {"api": None} + def device_discovered(service, info): + """Get called when an Octoprint server has been discovered.""" + _LOGGER.debug('Found an Octoprint server: %s', info) - try: - octoprint_api = OctoPrintAPI(base_url, api_key, bed, number_of_tools) - hass.data[DOMAIN]["api"] = octoprint_api - octoprint_api.get('printer') - octoprint_api.get('job') - except requests.exceptions.RequestException as conn_err: - _LOGGER.error("Error setting up OctoPrint API: %r", conn_err) + discovery.listen(hass, SERVICE_OCTOPRINT, device_discovered) - return True + for printer in config[DOMAIN]: + name = printer[CONF_NAME] + ssl = 's' if printer[CONF_SSL] else '' + base_url = 'http{}://{}:{}/api/'.format(ssl, + printer[CONF_HOST], + printer[CONF_PORT]) + api_key = printer[CONF_API_KEY] + number_of_tools = printer[CONF_NUMBER_OF_TOOLS] + bed = printer[CONF_BED] + try: + octoprint_api = OctoPrintAPI(base_url, api_key, bed, + number_of_tools) + printers[base_url] = octoprint_api + octoprint_api.get('printer') + octoprint_api.get('job') + except requests.exceptions.RequestException as conn_err: + _LOGGER.error("Error setting up OctoPrint API: %r", conn_err) + continue + + sensors = printer[CONF_SENSORS][CONF_MONITORED_CONDITIONS] + load_platform(hass, 'sensor', DOMAIN, {'name': name, + 'base_url': base_url, + 'sensors': sensors}) + b_sensors = printer[CONF_BINARY_SENSORS][CONF_MONITORED_CONDITIONS] + load_platform(hass, 'binary_sensor', DOMAIN, {'name': name, + 'base_url': base_url, + 'sensors': b_sensors}) + success = True + + return success class OctoPrintAPI: diff --git a/homeassistant/components/opentherm_gw.py b/homeassistant/components/opentherm_gw.py new file mode 100644 index 00000000000..08807a2d2a6 --- /dev/null +++ b/homeassistant/components/opentherm_gw.py @@ -0,0 +1,205 @@ +""" +Support for OpenTherm Gateway devices. + +For more details about this component, please refer to the documentation at +http://home-assistant.io/components/opentherm_gw/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import DOMAIN as COMP_BINARY_SENSOR +from homeassistant.components.sensor import DOMAIN as COMP_SENSOR +from homeassistant.const import (CONF_DEVICE, CONF_MONITORED_VARIABLES, + CONF_NAME, PRECISION_HALVES, PRECISION_TENTHS, + PRECISION_WHOLE) +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send + +import homeassistant.helpers.config_validation as cv + +DOMAIN = 'opentherm_gw' + +CONF_CLIMATE = 'climate' +CONF_FLOOR_TEMP = 'floor_temperature' +CONF_PRECISION = 'precision' + +DATA_DEVICE = 'device' +DATA_GW_VARS = 'gw_vars' +DATA_OPENTHERM_GW = 'opentherm_gw' + +SIGNAL_OPENTHERM_GW_UPDATE = 'opentherm_gw_update' + +CLIMATE_SCHEMA = vol.Schema({ + vol.Optional(CONF_NAME, default="OpenTherm Gateway"): cv.string, + vol.Optional(CONF_PRECISION): vol.In([PRECISION_TENTHS, PRECISION_HALVES, + PRECISION_WHOLE]), + vol.Optional(CONF_FLOOR_TEMP, default=False): cv.boolean, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DEVICE): cv.string, + vol.Optional(CONF_CLIMATE, default={}): CLIMATE_SCHEMA, + vol.Optional(CONF_MONITORED_VARIABLES, default=[]): vol.All( + cv.ensure_list, [cv.string]), + }), +}, extra=vol.ALLOW_EXTRA) + +REQUIREMENTS = ['pyotgw==0.2b1'] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Set up the OpenTherm Gateway component.""" + import pyotgw + conf = config[DOMAIN] + gateway = pyotgw.pyotgw() + monitored_vars = conf.get(CONF_MONITORED_VARIABLES) + hass.data[DATA_OPENTHERM_GW] = { + DATA_DEVICE: gateway, + DATA_GW_VARS: pyotgw.vars, + } + hass.async_create_task(connect_and_subscribe( + hass, conf[CONF_DEVICE], gateway)) + hass.async_create_task(async_load_platform( + hass, 'climate', DOMAIN, conf.get(CONF_CLIMATE))) + if monitored_vars: + hass.async_create_task(setup_monitored_vars(hass, monitored_vars)) + return True + + +async def connect_and_subscribe(hass, device_path, gateway): + """Connect to serial device and subscribe report handler.""" + await gateway.connect(hass.loop, device_path) + _LOGGER.debug("Connected to OpenTherm Gateway at %s", device_path) + + async def handle_report(status): + """Handle reports from the OpenTherm Gateway.""" + _LOGGER.debug("Received report: %s", status) + async_dispatcher_send(hass, SIGNAL_OPENTHERM_GW_UPDATE, status) + gateway.subscribe(handle_report) + + +async def setup_monitored_vars(hass, monitored_vars): + """Set up requested sensors.""" + gw_vars = hass.data[DATA_OPENTHERM_GW][DATA_GW_VARS] + sensor_type_map = { + COMP_BINARY_SENSOR: [ + gw_vars.DATA_MASTER_CH_ENABLED, + gw_vars.DATA_MASTER_DHW_ENABLED, + gw_vars.DATA_MASTER_COOLING_ENABLED, + gw_vars.DATA_MASTER_OTC_ENABLED, + gw_vars.DATA_MASTER_CH2_ENABLED, + gw_vars.DATA_SLAVE_FAULT_IND, + gw_vars.DATA_SLAVE_CH_ACTIVE, + gw_vars.DATA_SLAVE_DHW_ACTIVE, + gw_vars.DATA_SLAVE_FLAME_ON, + gw_vars.DATA_SLAVE_COOLING_ACTIVE, + gw_vars.DATA_SLAVE_CH2_ACTIVE, + gw_vars.DATA_SLAVE_DIAG_IND, + gw_vars.DATA_SLAVE_DHW_PRESENT, + gw_vars.DATA_SLAVE_CONTROL_TYPE, + gw_vars.DATA_SLAVE_COOLING_SUPPORTED, + gw_vars.DATA_SLAVE_DHW_CONFIG, + gw_vars.DATA_SLAVE_MASTER_LOW_OFF_PUMP, + gw_vars.DATA_SLAVE_CH2_PRESENT, + gw_vars.DATA_SLAVE_SERVICE_REQ, + gw_vars.DATA_SLAVE_REMOTE_RESET, + gw_vars.DATA_SLAVE_LOW_WATER_PRESS, + gw_vars.DATA_SLAVE_GAS_FAULT, + gw_vars.DATA_SLAVE_AIR_PRESS_FAULT, + gw_vars.DATA_SLAVE_WATER_OVERTEMP, + gw_vars.DATA_REMOTE_TRANSFER_DHW, + gw_vars.DATA_REMOTE_TRANSFER_MAX_CH, + gw_vars.DATA_REMOTE_RW_DHW, + gw_vars.DATA_REMOTE_RW_MAX_CH, + gw_vars.DATA_ROVRD_MAN_PRIO, + gw_vars.DATA_ROVRD_AUTO_PRIO, + gw_vars.OTGW_GPIO_A_STATE, + gw_vars.OTGW_GPIO_B_STATE, + gw_vars.OTGW_IGNORE_TRANSITIONS, + gw_vars.OTGW_OVRD_HB, + ], + COMP_SENSOR: [ + gw_vars.DATA_CONTROL_SETPOINT, + gw_vars.DATA_MASTER_MEMBERID, + gw_vars.DATA_SLAVE_MEMBERID, + gw_vars.DATA_SLAVE_OEM_FAULT, + gw_vars.DATA_COOLING_CONTROL, + gw_vars.DATA_CONTROL_SETPOINT_2, + gw_vars.DATA_ROOM_SETPOINT_OVRD, + gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD, + gw_vars.DATA_SLAVE_MAX_CAPACITY, + gw_vars.DATA_SLAVE_MIN_MOD_LEVEL, + gw_vars.DATA_ROOM_SETPOINT, + gw_vars.DATA_REL_MOD_LEVEL, + gw_vars.DATA_CH_WATER_PRESS, + gw_vars.DATA_DHW_FLOW_RATE, + gw_vars.DATA_ROOM_SETPOINT_2, + gw_vars.DATA_ROOM_TEMP, + gw_vars.DATA_CH_WATER_TEMP, + gw_vars.DATA_DHW_TEMP, + gw_vars.DATA_OUTSIDE_TEMP, + gw_vars.DATA_RETURN_WATER_TEMP, + gw_vars.DATA_SOLAR_STORAGE_TEMP, + gw_vars.DATA_SOLAR_COLL_TEMP, + gw_vars.DATA_CH_WATER_TEMP_2, + gw_vars.DATA_DHW_TEMP_2, + gw_vars.DATA_EXHAUST_TEMP, + gw_vars.DATA_SLAVE_DHW_MAX_SETP, + gw_vars.DATA_SLAVE_DHW_MIN_SETP, + gw_vars.DATA_SLAVE_CH_MAX_SETP, + gw_vars.DATA_SLAVE_CH_MIN_SETP, + gw_vars.DATA_DHW_SETPOINT, + gw_vars.DATA_MAX_CH_SETPOINT, + gw_vars.DATA_OEM_DIAG, + gw_vars.DATA_TOTAL_BURNER_STARTS, + gw_vars.DATA_CH_PUMP_STARTS, + gw_vars.DATA_DHW_PUMP_STARTS, + gw_vars.DATA_DHW_BURNER_STARTS, + gw_vars.DATA_TOTAL_BURNER_HOURS, + gw_vars.DATA_CH_PUMP_HOURS, + gw_vars.DATA_DHW_PUMP_HOURS, + gw_vars.DATA_DHW_BURNER_HOURS, + gw_vars.DATA_MASTER_OT_VERSION, + gw_vars.DATA_SLAVE_OT_VERSION, + gw_vars.DATA_MASTER_PRODUCT_TYPE, + gw_vars.DATA_MASTER_PRODUCT_VERSION, + gw_vars.DATA_SLAVE_PRODUCT_TYPE, + gw_vars.DATA_SLAVE_PRODUCT_VERSION, + gw_vars.OTGW_MODE, + gw_vars.OTGW_DHW_OVRD, + gw_vars.OTGW_ABOUT, + gw_vars.OTGW_BUILD, + gw_vars.OTGW_CLOCKMHZ, + gw_vars.OTGW_LED_A, + gw_vars.OTGW_LED_B, + gw_vars.OTGW_LED_C, + gw_vars.OTGW_LED_D, + gw_vars.OTGW_LED_E, + gw_vars.OTGW_LED_F, + gw_vars.OTGW_GPIO_A, + gw_vars.OTGW_GPIO_B, + gw_vars.OTGW_SB_TEMP, + gw_vars.OTGW_SETP_OVRD_MODE, + gw_vars.OTGW_SMART_PWR, + gw_vars.OTGW_THRM_DETECT, + gw_vars.OTGW_VREF, + ] + } + binary_sensors = [] + sensors = [] + for var in monitored_vars: + if var in sensor_type_map[COMP_SENSOR]: + sensors.append(var) + elif var in sensor_type_map[COMP_BINARY_SENSOR]: + binary_sensors.append(var) + else: + _LOGGER.error("Monitored variable not supported: %s", var) + if binary_sensors: + hass.async_create_task(async_load_platform( + hass, COMP_BINARY_SENSOR, DOMAIN, binary_sensors)) + if sensors: + await async_load_platform(hass, COMP_SENSOR, DOMAIN, sensors) diff --git a/homeassistant/components/openuv/.translations/pt.json b/homeassistant/components/openuv/.translations/pt.json index 36f875efc00..48283a74106 100644 --- a/homeassistant/components/openuv/.translations/pt.json +++ b/homeassistant/components/openuv/.translations/pt.json @@ -15,6 +15,6 @@ "title": "Preencha com as suas informa\u00e7\u00f5es" } }, - "title": "" + "title": "OpenUV" } } \ No newline at end of file diff --git a/homeassistant/components/openuv/.translations/ro.json b/homeassistant/components/openuv/.translations/ro.json new file mode 100644 index 00000000000..976221188d3 --- /dev/null +++ b/homeassistant/components/openuv/.translations/ro.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "identifier_exists": "Coordonatele deja \u00eenregistrate", + "invalid_api_key": "Cheie API invalid\u0103" + }, + "step": { + "user": { + "data": { + "api_key": "Cheie API OpenUV", + "elevation": "Altitudine", + "latitude": "Latitudine", + "longitude": "Longitudine" + }, + "title": "Completa\u021bi informa\u021biile dvs." + } + }, + "title": "OpenUV" + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index 8485e1e3201..a45d9ceb0d6 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -14,13 +14,14 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_API_KEY, CONF_BINARY_SENSORS, CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL, CONF_SENSORS) +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from .config_flow import configured_instances -from .const import DOMAIN +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN REQUIREMENTS = ['pyopenuv==1.0.4'] _LOGGER = logging.getLogger(__name__) @@ -31,7 +32,6 @@ DATA_PROTECTION_WINDOW = 'protection_window' DATA_UV = 'uv' DEFAULT_ATTRIBUTION = 'Data provided by OpenUV' -DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) NOTIFICATION_ID = 'openuv_notification' NOTIFICATION_TITLE = 'OpenUV Component Setup' @@ -85,18 +85,17 @@ SENSOR_SCHEMA = vol.Schema({ }) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: - vol.Schema({ - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_ELEVATION): float, - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, - vol.Optional(CONF_BINARY_SENSORS, default={}): - BINARY_SENSOR_SCHEMA, - vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, - vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): - cv.time_period, - }) + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_ELEVATION): float, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_BINARY_SENSORS, default={}): + BINARY_SENSOR_SCHEMA, + vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + cv.time_period, + }) }, extra=vol.ALLOW_EXTRA) @@ -110,27 +109,26 @@ async def async_setup(hass, config): return True conf = config[DOMAIN] - latitude = conf.get(CONF_LATITUDE, hass.config.latitude) - longitude = conf.get(CONF_LONGITUDE, hass.config.longitude) - elevation = conf.get(CONF_ELEVATION, hass.config.elevation) + latitude = conf.get(CONF_LATITUDE) + longitude = conf.get(CONF_LONGITUDE) identifier = '{0}, {1}'.format(latitude, longitude) + if identifier in configured_instances(hass): + return True - if identifier not in configured_instances(hass): - hass.async_add_job( - hass.config_entries.flow.async_init( - DOMAIN, - context={'source': SOURCE_IMPORT}, - data={ - CONF_API_KEY: conf[CONF_API_KEY], - CONF_LATITUDE: latitude, - CONF_LONGITUDE: longitude, - CONF_ELEVATION: elevation, - CONF_BINARY_SENSORS: conf[CONF_BINARY_SENSORS], - CONF_SENSORS: conf[CONF_SENSORS], - })) - - hass.data[DOMAIN][CONF_SCAN_INTERVAL] = conf[CONF_SCAN_INTERVAL] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': SOURCE_IMPORT}, + data={ + CONF_API_KEY: conf[CONF_API_KEY], + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_ELEVATION: conf.get(CONF_ELEVATION), + CONF_BINARY_SENSORS: conf[CONF_BINARY_SENSORS], + CONF_SENSORS: conf[CONF_SENSORS], + CONF_SCAN_INTERVAL: conf[CONF_SCAN_INTERVAL], + })) return True @@ -145,11 +143,10 @@ async def async_setup_entry(hass, config_entry): openuv = OpenUV( Client( config_entry.data[CONF_API_KEY], - config_entry.data.get(CONF_LATITUDE, hass.config.latitude), - config_entry.data.get(CONF_LONGITUDE, hass.config.longitude), + config_entry.data[CONF_LATITUDE], + config_entry.data[CONF_LONGITUDE], websession, - altitude=config_entry.data.get( - CONF_ELEVATION, hass.config.elevation)), + altitude=config_entry.data[CONF_ELEVATION]), config_entry.data.get(CONF_BINARY_SENSORS, {}).get( CONF_MONITORED_CONDITIONS, list(BINARY_SENSORS)), config_entry.data.get(CONF_SENSORS, {}).get( @@ -157,20 +154,14 @@ async def async_setup_entry(hass, config_entry): await openuv.async_update() hass.data[DOMAIN][DATA_OPENUV_CLIENT][config_entry.entry_id] = openuv except OpenUvError as err: - _LOGGER.error('An error occurred: %s', str(err)) - hass.components.persistent_notification.create( - 'Error: {0}
' - 'You will need to restart hass after fixing.' - ''.format(err), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) - return False + _LOGGER.error('Config entry failed: %s', err) + raise ConfigEntryNotReady for component in ('binary_sensor', 'sensor'): hass.async_create_task(hass.config_entries.async_forward_entry_setup( config_entry, component)) - async def refresh_sensors(event_time): + async def refresh(event_time): """Refresh OpenUV data.""" _LOGGER.debug('Refreshing OpenUV data') await openuv.async_update() @@ -178,24 +169,25 @@ async def async_setup_entry(hass, config_entry): hass.data[DOMAIN][DATA_OPENUV_LISTENER][ config_entry.entry_id] = async_track_time_interval( - hass, refresh_sensors, - hass.data[DOMAIN].get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)) + hass, + refresh, + timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL])) return True async def async_unload_entry(hass, config_entry): """Unload an OpenUV config entry.""" - for component in ('binary_sensor', 'sensor'): - await hass.config_entries.async_forward_entry_unload( - config_entry, component) - hass.data[DOMAIN][DATA_OPENUV_CLIENT].pop(config_entry.entry_id) remove_listener = hass.data[DOMAIN][DATA_OPENUV_LISTENER].pop( config_entry.entry_id) remove_listener() + for component in ('binary_sensor', 'sensor'): + await hass.config_entries.async_forward_entry_unload( + config_entry, component) + return True diff --git a/homeassistant/components/openuv/config_flow.py b/homeassistant/components/openuv/config_flow.py index 6d7ae0f65bd..27ffe5c3985 100644 --- a/homeassistant/components/openuv/config_flow.py +++ b/homeassistant/components/openuv/config_flow.py @@ -1,16 +1,15 @@ """Config flow to configure the OpenUV component.""" -from collections import OrderedDict - import voluptuous as vol from homeassistant import config_entries from homeassistant.core import callback from homeassistant.const import ( - CONF_API_KEY, CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE) + CONF_API_KEY, CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, + CONF_SCAN_INTERVAL) from homeassistant.helpers import aiohttp_client, config_validation as cv -from .const import DOMAIN +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN @callback @@ -33,6 +32,24 @@ class OpenUvFlowHandler(config_entries.ConfigFlow): """Initialize the config flow.""" pass + async def _show_form(self, errors=None): + """Show the form to the user.""" + data_schema = vol.Schema({ + vol.Required(CONF_API_KEY): str, + vol.Optional(CONF_LATITUDE, default=self.hass.config.latitude): + cv.latitude, + vol.Optional(CONF_LONGITUDE, default=self.hass.config.longitude): + cv.longitude, + vol.Optional(CONF_ELEVATION, default=self.hass.config.elevation): + vol.Coerce(float), + }) + + return self.async_show_form( + step_id='user', + data_schema=data_schema, + errors=errors if errors else {}, + ) + async def async_step_import(self, import_config): """Import a config entry from configuration.yaml.""" return await self.async_step_user(import_config) @@ -41,34 +58,31 @@ class OpenUvFlowHandler(config_entries.ConfigFlow): """Handle the start of the config flow.""" from pyopenuv.util import validate_api_key - errors = {} + if not user_input: + return await self._show_form() - if user_input is not None: - identifier = '{0}, {1}'.format( - user_input.get(CONF_LATITUDE, self.hass.config.latitude), - user_input.get(CONF_LONGITUDE, self.hass.config.longitude)) + latitude = user_input[CONF_LATITUDE] + longitude = user_input[CONF_LONGITUDE] + elevation = user_input[CONF_ELEVATION] - if identifier in configured_instances(self.hass): - errors['base'] = 'identifier_exists' - else: - websession = aiohttp_client.async_get_clientsession(self.hass) - api_key_validation = await validate_api_key( - user_input[CONF_API_KEY], websession) - if api_key_validation: - return self.async_create_entry( - title=identifier, - data=user_input, - ) - errors['base'] = 'invalid_api_key' + identifier = '{0}, {1}'.format(latitude, longitude) + if identifier in configured_instances(self.hass): + return await self._show_form({CONF_LATITUDE: 'identifier_exists'}) - data_schema = OrderedDict() - data_schema[vol.Required(CONF_API_KEY)] = str - data_schema[vol.Optional(CONF_LATITUDE)] = cv.latitude - data_schema[vol.Optional(CONF_LONGITUDE)] = cv.longitude - data_schema[vol.Optional(CONF_ELEVATION)] = vol.Coerce(float) + websession = aiohttp_client.async_get_clientsession(self.hass) + api_key_validation = await validate_api_key( + user_input[CONF_API_KEY], websession) - return self.async_show_form( - step_id='user', - data_schema=vol.Schema(data_schema), - errors=errors, - ) + if not api_key_validation: + return await self._show_form({CONF_API_KEY: 'invalid_api_key'}) + + scan_interval = user_input.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + user_input.update({ + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_ELEVATION: elevation, + CONF_SCAN_INTERVAL: scan_interval.seconds, + }) + + return self.async_create_entry(title=identifier, data=user_input) diff --git a/homeassistant/components/openuv/const.py b/homeassistant/components/openuv/const.py index 1aa3d2abcaa..16623e45642 100644 --- a/homeassistant/components/openuv/const.py +++ b/homeassistant/components/openuv/const.py @@ -1,3 +1,6 @@ """Define constants for the OpenUV component.""" +from datetime import timedelta DOMAIN = 'openuv' + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) diff --git a/homeassistant/components/raspihats.py b/homeassistant/components/raspihats.py index f43263bf4bf..5e834cdf7ec 100644 --- a/homeassistant/components/raspihats.py +++ b/homeassistant/components/raspihats.py @@ -4,7 +4,6 @@ Support for controlling raspihats boards. For more details about this component, please refer to the documentation at https://home-assistant.io/components/raspihats/ """ -# pylint: disable=import-error,no-name-in-module import logging import threading import time @@ -125,7 +124,7 @@ class I2CHatsManager(threading.Thread): with self._lock: i2c_hat = self._i2c_hats.get(address) if i2c_hat is None: - # pylint: disable=import-error + # pylint: disable=import-error,no-name-in-module import raspihats.i2c_hats as module constructor = getattr(module, board) i2c_hat = constructor(address) @@ -143,6 +142,7 @@ class I2CHatsManager(threading.Thread): def run(self): """Keep alive for I2C-HATs.""" + # pylint: disable=import-error,no-name-in-module from raspihats.i2c_hats import ResponseException _LOGGER.info(log_message(self, "starting")) @@ -205,6 +205,7 @@ class I2CHatsManager(threading.Thread): def read_di(self, address, channel): """Read a value from a I2C-HAT digital input.""" + # pylint: disable=import-error,no-name-in-module from raspihats.i2c_hats import ResponseException with self._lock: @@ -217,6 +218,7 @@ class I2CHatsManager(threading.Thread): def write_dq(self, address, channel, value): """Write a value to a I2C-HAT digital output.""" + # pylint: disable=import-error,no-name-in-module from raspihats.i2c_hats import ResponseException with self._lock: @@ -228,6 +230,7 @@ class I2CHatsManager(threading.Thread): def read_dq(self, address, channel): """Read a value from a I2C-HAT digital output.""" + # pylint: disable=import-error,no-name-in-module from raspihats.i2c_hats import ResponseException with self._lock: diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py index 7cd588683de..5a914fc3652 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -22,7 +22,7 @@ from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -REQUIREMENTS = ['python-miio==0.4.1', 'construct==2.9.41'] +REQUIREMENTS = ['python-miio==0.4.2', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/rflink.py b/homeassistant/components/rflink.py index a8aeca273d6..3bb3bb7044b 100644 --- a/homeassistant/components/rflink.py +++ b/homeassistant/components/rflink.py @@ -6,7 +6,6 @@ https://home-assistant.io/components/rflink/ """ import asyncio from collections import defaultdict -import functools as ft import logging import async_timeout @@ -14,7 +13,7 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, CONF_COMMAND, CONF_HOST, CONF_PORT, - EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) + EVENT_HOMEASSISTANT_STOP) from homeassistant.core import CoreState, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv @@ -68,6 +67,9 @@ DOMAIN = 'rflink' SERVICE_SEND_COMMAND = 'send_command' SIGNAL_AVAILABILITY = 'rflink_device_available' +SIGNAL_HANDLE_EVENT = 'rflink_handle_event_{}' + +TMP_ENTITY = 'tmp.{}' DEVICE_DEFAULTS_SCHEMA = vol.Schema({ vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, @@ -118,7 +120,6 @@ async def async_setup(hass, config): } hass.data[DATA_ENTITY_GROUP_LOOKUP] = { EVENT_KEY_COMMAND: defaultdict(list), - EVENT_KEY_SENSOR: defaultdict(list), } # Allow platform to specify function to register new unknown devices @@ -153,28 +154,38 @@ async def async_setup(hass, config): return # Lookup entities who registered this device id as device id or alias - event_id = event.get('id', None) + event_id = event.get(EVENT_KEY_ID, None) is_group_event = (event_type == EVENT_KEY_COMMAND and event[EVENT_KEY_COMMAND] in RFLINK_GROUP_COMMANDS) if is_group_event: - entities = hass.data[DATA_ENTITY_GROUP_LOOKUP][event_type].get( + entity_ids = hass.data[DATA_ENTITY_GROUP_LOOKUP][event_type].get( event_id, []) else: - entities = hass.data[DATA_ENTITY_LOOKUP][event_type][event_id] + entity_ids = hass.data[DATA_ENTITY_LOOKUP][event_type][event_id] - if entities: + _LOGGER.debug('entity_ids: %s', entity_ids) + if entity_ids: # Propagate event to every entity matching the device id - for entity in entities: - _LOGGER.debug('passing event to %s', entities) - entity.handle_event(event) - else: - _LOGGER.debug('device_id not known, adding new device') - + for entity in entity_ids: + _LOGGER.debug('passing event to %s', entity) + async_dispatcher_send(hass, + SIGNAL_HANDLE_EVENT.format(entity), + event) + elif not is_group_event: # If device is not yet known, register with platform (if loaded) if event_type in hass.data[DATA_DEVICE_REGISTER]: - hass.async_run_job( - hass.data[DATA_DEVICE_REGISTER][event_type], event) + _LOGGER.debug('device_id not known, adding new device') + # Add bogus event_id first to avoid race if we get another + # event before the device is created + # Any additional events recevied before the device has been + # created will thus be ignored. + hass.data[DATA_ENTITY_LOOKUP][event_type][ + event_id].append(TMP_ENTITY.format(event_id)) + hass.async_create_task( + hass.data[DATA_DEVICE_REGISTER][event_type](event)) + else: + _LOGGER.debug('device_id not known and automatic add disabled') # When connecting to tcp host instead of serial port (optional) host = config[DOMAIN].get(CONF_HOST) @@ -192,7 +203,7 @@ async def async_setup(hass, config): # If HA is not stopping, initiate new connection if hass.state != CoreState.stopping: _LOGGER.warning('disconnected from Rflink, reconnecting') - hass.async_add_job(connect) + hass.async_create_task(connect()) async def connect(): """Set up connection and hook it into HA for reconnect/shutdown.""" @@ -242,7 +253,7 @@ async def async_setup(hass, config): _LOGGER.info('Connected to Rflink') - hass.async_add_job(connect) + hass.async_create_task(connect()) return True @@ -253,26 +264,31 @@ class RflinkDevice(Entity): """ platform = None - _state = STATE_UNKNOWN + _state = None _available = True - def __init__(self, device_id, hass, name=None, aliases=None, group=True, - group_aliases=None, nogroup_aliases=None, fire_event=False, + def __init__(self, device_id, initial_event=None, name=None, aliases=None, + group=True, group_aliases=None, nogroup_aliases=None, + fire_event=False, signal_repetitions=DEFAULT_SIGNAL_REPETITIONS): """Initialize the device.""" - self.hass = hass - # Rflink specific attributes for every component type + self._initial_event = initial_event self._device_id = device_id if name: self._name = name else: self._name = device_id + self._aliases = aliases + self._group = group + self._group_aliases = group_aliases + self._nogroup_aliases = nogroup_aliases self._should_fire_event = fire_event self._signal_repetitions = signal_repetitions - def handle_event(self, event): + @callback + def handle_event_callback(self, event): """Handle incoming event for device type.""" # Call platform specific event handler self._handle_event(event) @@ -283,7 +299,7 @@ class RflinkDevice(Entity): # Put command onto bus for user to subscribe to if self._should_fire_event and identify_event_type( event) == EVENT_KEY_COMMAND: - self.hass.bus.fire(EVENT_BUTTON_PRESSED, { + self.hass.bus.async_fire(EVENT_BUTTON_PRESSED, { ATTR_ENTITY_ID: self.entity_id, ATTR_STATE: event[EVENT_KEY_COMMAND], }) @@ -314,7 +330,7 @@ class RflinkDevice(Entity): @property def assumed_state(self): """Assume device state until first device event sets state.""" - return self._state is STATE_UNKNOWN + return self._state is None @property def available(self): @@ -322,15 +338,52 @@ class RflinkDevice(Entity): return self._available @callback - def set_availability(self, availability): + def _availability_callback(self, availability): """Update availability state.""" self._available = availability self.async_schedule_update_ha_state() async def async_added_to_hass(self): """Register update callback.""" + # Remove temporary bogus entity_id if added + tmp_entity = TMP_ENTITY.format(self._device_id) + if tmp_entity in self.hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_SENSOR][self._device_id]: + self.hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_SENSOR][self._device_id].remove(tmp_entity) + + # Register id and aliases + self.hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_COMMAND][self._device_id].append(self.entity_id) + if self._group: + self.hass.data[DATA_ENTITY_GROUP_LOOKUP][ + EVENT_KEY_COMMAND][self._device_id].append(self.entity_id) + # aliases respond to both normal and group commands (allon/alloff) + if self._aliases: + for _id in self._aliases: + self.hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(self.entity_id) + self.hass.data[DATA_ENTITY_GROUP_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(self.entity_id) + # group_aliases only respond to group commands (allon/alloff) + if self._group_aliases: + for _id in self._group_aliases: + self.hass.data[DATA_ENTITY_GROUP_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(self.entity_id) + # nogroup_aliases only respond to normal commands + if self._nogroup_aliases: + for _id in self._nogroup_aliases: + self.hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(self.entity_id) async_dispatcher_connect(self.hass, SIGNAL_AVAILABILITY, - self.set_availability) + self._availability_callback) + async_dispatcher_connect(self.hass, + SIGNAL_HANDLE_EVENT.format(self.entity_id), + self.handle_event_callback) + + # Process the initial event now that the entity is created + if self._initial_event: + self.handle_event_callback(self._initial_event) class RflinkCommand(RflinkDevice): @@ -388,7 +441,7 @@ class RflinkCommand(RflinkDevice): cmd = 'on' # if the state is unknown or false, it gets set as true # if the state is true, it gets set as false - self._state = self._state in [STATE_UNKNOWN, False] + self._state = self._state in [None, False] # Cover options for RFlink elif command == 'close_cover': @@ -439,8 +492,8 @@ class RflinkCommand(RflinkDevice): # Rflink protocol/transport handles asynchronous writing of buffer # to serial/tcp device. Does not wait for command send # confirmation. - self.hass.async_add_job(ft.partial( - self._protocol.send_command, self._device_id, cmd)) + self.hass.async_create_task(self._protocol.send_command( + self._device_id, cmd)) if repetitions > 1: self._repetition_task = self.hass.async_create_task( diff --git a/homeassistant/components/route53.py b/homeassistant/components/route53.py new file mode 100644 index 00000000000..f88a15b72b8 --- /dev/null +++ b/homeassistant/components/route53.py @@ -0,0 +1,113 @@ +""" +Update the IP addresses of your Route53 DNS records. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/route53/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_DOMAIN, CONF_ZONE +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_time_interval + +REQUIREMENTS = ['boto3==1.9.16', 'ipify==1.0.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ACCESS_KEY_ID = 'aws_access_key_id' +CONF_SECRET_ACCESS_KEY = 'aws_secret_access_key' +CONF_RECORDS = 'records' + +DOMAIN = 'route53' + +INTERVAL = timedelta(minutes=60) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_ACCESS_KEY_ID): cv.string, + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_RECORDS): vol.All(cv.ensure_list, [cv.string]), + vol.Required(CONF_SECRET_ACCESS_KEY): cv.string, + vol.Required(CONF_ZONE): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Route53 component.""" + domain = config[DOMAIN][CONF_DOMAIN] + records = config[DOMAIN][CONF_RECORDS] + zone = config[DOMAIN][CONF_ZONE] + aws_access_key_id = config[DOMAIN][CONF_ACCESS_KEY_ID] + aws_secret_access_key = config[DOMAIN][CONF_SECRET_ACCESS_KEY] + + def update_records_interval(now): + """Set up recurring update.""" + _update_route53( + aws_access_key_id, aws_secret_access_key, zone, domain, records) + + def update_records_service(now): + """Set up service for manual trigger.""" + _update_route53( + aws_access_key_id, aws_secret_access_key, zone, domain, records) + + track_time_interval(hass, update_records_interval, INTERVAL) + + hass.services.register(DOMAIN, 'update_records', update_records_service) + return True + + +def _update_route53( + aws_access_key_id, aws_secret_access_key, zone, domain, records): + import boto3 + from ipify import get_ip + from ipify import exceptions + + _LOGGER.debug("Starting update for zone %s", zone) + + client = boto3.client( + DOMAIN, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + ) + + # Get the IP Address and build an array of changes + try: + ipaddress = get_ip() + + except exceptions.ConnectionError: + _LOGGER.warning("Unable to reach the ipify service") + return + + except exceptions.ServiceError: + _LOGGER.warning("Unable to complete the ipfy request") + return + + changes = [] + for record in records: + _LOGGER.debug("Processing record: %s", record) + + changes.append({ + 'Action': 'UPSERT', + 'ResourceRecordSet': { + 'Name': '{}.{}'.format(record, domain), + 'Type': 'A', + 'TTL': 300, + 'ResourceRecords': [ + {'Value': ipaddress}, + ], + } + }) + + _LOGGER.debug("Submitting the following changes to Route53") + _LOGGER.debug(changes) + + response = client.change_resource_record_sets( + HostedZoneId=zone, ChangeBatch={'Changes': changes}) + _LOGGER.debug("Response is %s", response) + + if response['ResponseMetadata']['HTTPStatusCode'] != 200: + _LOGGER.warning(response) diff --git a/homeassistant/components/rpi_gpio.py b/homeassistant/components/rpi_gpio.py index 824ec46d636..09521803aa9 100644 --- a/homeassistant/components/rpi_gpio.py +++ b/homeassistant/components/rpi_gpio.py @@ -4,7 +4,6 @@ Support for controlling GPIO pins of a Raspberry Pi. For more details about this component, please refer to the documentation at https://home-assistant.io/components/rpi_gpio/ """ -# pylint: disable=import-error import logging from homeassistant.const import ( @@ -19,7 +18,7 @@ DOMAIN = 'rpi_gpio' def setup(hass, config): """Set up the Raspberry PI GPIO component.""" - from RPi import GPIO + from RPi import GPIO # pylint: disable=import-error def cleanup_gpio(event): """Stuff to do before stopping.""" @@ -36,32 +35,32 @@ def setup(hass, config): def setup_output(port): """Set up a GPIO as output.""" - from RPi import GPIO + from RPi import GPIO # pylint: disable=import-error GPIO.setup(port, GPIO.OUT) def setup_input(port, pull_mode): """Set up a GPIO as input.""" - from RPi import GPIO + from RPi import GPIO # pylint: disable=import-error GPIO.setup(port, GPIO.IN, GPIO.PUD_DOWN if pull_mode == 'DOWN' else GPIO.PUD_UP) def write_output(port, value): """Write a value to a GPIO.""" - from RPi import GPIO + from RPi import GPIO # pylint: disable=import-error GPIO.output(port, value) def read_input(port): """Read a value from a GPIO.""" - from RPi import GPIO + from RPi import GPIO # pylint: disable=import-error return GPIO.input(port) def edge_detect(port, event_callback, bounce): """Add detection for RISING and FALLING events.""" - from RPi import GPIO + from RPi import GPIO # pylint: disable=import-error GPIO.add_event_detect( port, GPIO.BOTH, diff --git a/homeassistant/components/sabnzbd.py b/homeassistant/components/sabnzbd.py index 380867a3285..25fce22c641 100644 --- a/homeassistant/components/sabnzbd.py +++ b/homeassistant/components/sabnzbd.py @@ -15,11 +15,12 @@ from homeassistant.const import ( CONF_HOST, CONF_API_KEY, CONF_NAME, CONF_PORT, CONF_SENSORS, CONF_SSL) from homeassistant.core import callback from homeassistant.helpers import discovery +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['pysabnzbd==1.0.1'] +REQUIREMENTS = ['pysabnzbd==1.1.0'] _LOGGER = logging.getLogger(__name__) @@ -102,7 +103,8 @@ async def async_configure_sabnzbd(hass, config, use_ssl, name=DEFAULT_NAME, hass.config.path(CONFIG_FILE)) api_key = conf.get(base_url, {}).get(CONF_API_KEY, '') - sab_api = SabnzbdApi(base_url, api_key) + sab_api = SabnzbdApi(base_url, api_key, + session=async_get_clientsession(hass)) if await async_check_sabnzbd(sab_api): async_setup_sabnzbd(hass, sab_api, config, name) else: @@ -188,7 +190,8 @@ def async_request_configuration(hass, config, host): async def async_configuration_callback(data): """Handle configuration changes.""" api_key = data.get(CONF_API_KEY) - sab_api = SabnzbdApi(host, api_key) + sab_api = SabnzbdApi(host, api_key, + session=async_get_clientsession(hass)) if not await async_check_sabnzbd(sab_api): return diff --git a/homeassistant/components/scene/elkm1.py b/homeassistant/components/scene/elkm1.py new file mode 100644 index 00000000000..47dd17a56ae --- /dev/null +++ b/homeassistant/components/scene/elkm1.py @@ -0,0 +1,31 @@ +""" +Support for control of ElkM1 tasks ("macros"). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.elkm1/ +""" + + +from homeassistant.components.elkm1 import ( + DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities) +from homeassistant.components.scene import Scene + +DEPENDENCIES = [ELK_DOMAIN] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Create the Elk-M1 scene platform.""" + if discovery_info is None: + return + elk = hass.data[ELK_DOMAIN]['elk'] + entities = create_elk_entities(hass, elk.tasks, 'task', ElkTask, []) + async_add_entities(entities, True) + + +class ElkTask(ElkEntity, Scene): + """Elk-M1 task as scene.""" + + async def async_activate(self): + """Activate the task.""" + self._element.activate() diff --git a/homeassistant/components/scsgate.py b/homeassistant/components/scsgate.py index dcea69cbb48..79bf4e217c9 100644 --- a/homeassistant/components/scsgate.py +++ b/homeassistant/components/scsgate.py @@ -42,11 +42,10 @@ def setup(hass, config): device = config[DOMAIN][CONF_DEVICE] global SCSGATE - # pylint: disable=broad-except try: SCSGATE = SCSGate(device=device, logger=_LOGGER) SCSGATE.start() - except Exception as exception: + except Exception as exception: # pylint: disable=broad-except _LOGGER.error("Cannot setup SCSGate component: %s", exception) return False @@ -101,10 +100,9 @@ class SCSGate: if new_device_activated: self._activate_next_device() - # pylint: disable=broad-except try: self._devices[message.entity].process_event(message) - except Exception as exception: + except Exception as exception: # pylint: disable=broad-except msg = "Exception while processing event: {}".format(exception) self._logger.error(msg) else: diff --git a/homeassistant/components/sensor/.translations/moon.ro.json b/homeassistant/components/sensor/.translations/moon.ro.json new file mode 100644 index 00000000000..6f64e497c74 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.ro.json @@ -0,0 +1,6 @@ +{ + "state": { + "full_moon": "Lun\u0103 plin\u0103", + "new_moon": "Lun\u0103 nou\u0103" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/api_streams.py b/homeassistant/components/sensor/api_streams.py index ac9b15754d0..1fbef2c5896 100644 --- a/homeassistant/components/sensor/api_streams.py +++ b/homeassistant/components/sensor/api_streams.py @@ -2,7 +2,7 @@ Entity to track connections to stream API. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.api_stream/ +https://home-assistant.io/components/sensor.api_streams/ """ import logging @@ -47,9 +47,9 @@ class StreamHandler(logging.Handler): self.entity.schedule_update_ha_state() -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the API stream platform.""" +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the API streams platform.""" entity = APICount() handler = StreamHandler(entity) @@ -68,7 +68,7 @@ async def async_setup_platform(hass, config, async_add_entities, class APICount(Entity): - """Entity to represent how many people are connected to stream API.""" + """Entity to represent how many people are connected to the stream API.""" def __init__(self): """Initialize the API count.""" diff --git a/homeassistant/components/sensor/bh1750.py b/homeassistant/components/sensor/bh1750.py index fb0a0116818..592a6acbe58 100644 --- a/homeassistant/components/sensor/bh1750.py +++ b/homeassistant/components/sensor/bh1750.py @@ -64,12 +64,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=import-error async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the BH1750 sensor.""" - import smbus - from i2csense.bh1750 import BH1750 + import smbus # pylint: disable=import-error + from i2csense.bh1750 import BH1750 # pylint: disable=import-error name = config.get(CONF_NAME) bus_number = config.get(CONF_I2C_BUS) diff --git a/homeassistant/components/sensor/bloomsky.py b/homeassistant/components/sensor/bloomsky.py index 8926848102c..02cd456107f 100644 --- a/homeassistant/components/sensor/bloomsky.py +++ b/homeassistant/components/sensor/bloomsky.py @@ -63,6 +63,12 @@ class BloomSkySensor(Entity): self._sensor_name = sensor_name self._name = '{} {}'.format(device['DeviceName'], sensor_name) self._state = None + self._unique_id = '{}-{}'.format(self._device_id, self._sensor_name) + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id @property def name(self): diff --git a/homeassistant/components/sensor/bme280.py b/homeassistant/components/sensor/bme280.py index f67dace817e..a6b773040ef 100644 --- a/homeassistant/components/sensor/bme280.py +++ b/homeassistant/components/sensor/bme280.py @@ -79,12 +79,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=import-error async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the BME280 sensor.""" - import smbus - from i2csense.bme280 import BME280 + import smbus # pylint: disable=import-error + from i2csense.bme280 import BME280 # pylint: disable=import-error SENSOR_TYPES[SENSOR_TEMP][1] = hass.config.units.temperature_unit name = config.get(CONF_NAME) diff --git a/homeassistant/components/sensor/bme680.py b/homeassistant/components/sensor/bme680.py index c4e8baf6c05..cbcb7f1080e 100644 --- a/homeassistant/components/sensor/bme680.py +++ b/homeassistant/components/sensor/bme680.py @@ -115,15 +115,15 @@ async def async_setup_platform(hass, config, async_add_entities, return -# pylint: disable=import-error, no-member def _setup_bme680(config): """Set up and configure the BME680 sensor.""" - from smbus import SMBus + from smbus import SMBus # pylint: disable=import-error import bme680 sensor_handler = None sensor = None try: + # pylint: disable=no-member i2c_address = config.get(CONF_I2C_ADDRESS) bus = SMBus(config.get(CONF_I2C_BUS)) sensor = bme680.BME680(i2c_address, bus) diff --git a/homeassistant/components/sensor/bmw_connected_drive.py b/homeassistant/components/sensor/bmw_connected_drive.py index 964a8a4cb16..a7ee5724d19 100644 --- a/homeassistant/components/sensor/bmw_connected_drive.py +++ b/homeassistant/components/sensor/bmw_connected_drive.py @@ -9,18 +9,32 @@ import logging from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level +from homeassistant.const import (CONF_UNIT_SYSTEM_IMPERIAL, VOLUME_LITERS, + VOLUME_GALLONS, LENGTH_KILOMETERS, + LENGTH_MILES) DEPENDENCIES = ['bmw_connected_drive'] _LOGGER = logging.getLogger(__name__) -ATTR_TO_HA = { - 'mileage': ['mdi:speedometer', 'km'], - 'remaining_range_total': ['mdi:ruler', 'km'], - 'remaining_range_electric': ['mdi:ruler', 'km'], - 'remaining_range_fuel': ['mdi:ruler', 'km'], - 'max_range_electric': ['mdi:ruler', 'km'], - 'remaining_fuel': ['mdi:gas-station', 'l'], +ATTR_TO_HA_METRIC = { + 'mileage': ['mdi:speedometer', LENGTH_KILOMETERS], + 'remaining_range_total': ['mdi:ruler', LENGTH_KILOMETERS], + 'remaining_range_electric': ['mdi:ruler', LENGTH_KILOMETERS], + 'remaining_range_fuel': ['mdi:ruler', LENGTH_KILOMETERS], + 'max_range_electric': ['mdi:ruler', LENGTH_KILOMETERS], + 'remaining_fuel': ['mdi:gas-station', VOLUME_LITERS], + 'charging_time_remaining': ['mdi:update', 'h'], + 'charging_status': ['mdi:battery-charging', None] +} + +ATTR_TO_HA_IMPERIAL = { + 'mileage': ['mdi:speedometer', LENGTH_MILES], + 'remaining_range_total': ['mdi:ruler', LENGTH_MILES], + 'remaining_range_electric': ['mdi:ruler', LENGTH_MILES], + 'remaining_range_fuel': ['mdi:ruler', LENGTH_MILES], + 'max_range_electric': ['mdi:ruler', LENGTH_MILES], + 'remaining_fuel': ['mdi:gas-station', VOLUME_GALLONS], 'charging_time_remaining': ['mdi:update', 'h'], 'charging_status': ['mdi:battery-charging', None] } @@ -28,6 +42,11 @@ ATTR_TO_HA = { def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the BMW sensors.""" + if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + attribute_info = ATTR_TO_HA_IMPERIAL + else: + attribute_info = ATTR_TO_HA_METRIC + accounts = hass.data[BMW_DOMAIN] _LOGGER.debug('Found BMW accounts: %s', ', '.join([a.name for a in accounts])) @@ -36,9 +55,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for vehicle in account.account.vehicles: for attribute_name in vehicle.drive_train_attributes: device = BMWConnectedDriveSensor(account, vehicle, - attribute_name) + attribute_name, + attribute_info) devices.append(device) - device = BMWConnectedDriveSensor(account, vehicle, 'mileage') + device = BMWConnectedDriveSensor(account, vehicle, 'mileage', + attribute_info) devices.append(device) add_entities(devices, True) @@ -46,7 +67,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BMWConnectedDriveSensor(Entity): """Representation of a BMW vehicle sensor.""" - def __init__(self, account, vehicle, attribute: str): + def __init__(self, account, vehicle, attribute: str, attribute_info): """Constructor.""" self._vehicle = vehicle self._account = account @@ -54,6 +75,7 @@ class BMWConnectedDriveSensor(Entity): self._state = None self._name = '{} {}'.format(self._vehicle.name, self._attribute) self._unique_id = '{}-{}'.format(self._vehicle.vin, self._attribute) + self._attribute_info = attribute_info @property def should_poll(self) -> bool: @@ -78,14 +100,14 @@ class BMWConnectedDriveSensor(Entity): """Icon to use in the frontend, if any.""" from bimmer_connected.state import ChargingState vehicle_state = self._vehicle.state - charging_state = vehicle_state.charging_status in \ - [ChargingState.CHARGING] + charging_state = vehicle_state.charging_status in [ + ChargingState.CHARGING] if self._attribute == 'charging_level_hv': return icon_for_battery_level( battery_level=vehicle_state.charging_level_hv, charging=charging_state) - icon, _ = ATTR_TO_HA.get(self._attribute, [None, None]) + icon, _ = self._attribute_info.get(self._attribute, [None, None]) return icon @property @@ -100,7 +122,7 @@ class BMWConnectedDriveSensor(Entity): @property def unit_of_measurement(self) -> str: """Get the unit of measurement.""" - _, unit = ATTR_TO_HA.get(self._attribute, [None, None]) + _, unit = self._attribute_info.get(self._attribute, [None, None]) return unit @property @@ -116,6 +138,16 @@ class BMWConnectedDriveSensor(Entity): vehicle_state = self._vehicle.state if self._attribute == 'charging_status': self._state = getattr(vehicle_state, self._attribute).value + elif self.unit_of_measurement == VOLUME_GALLONS: + value = getattr(vehicle_state, self._attribute) + value_converted = self.hass.config.units.volume(value, + VOLUME_LITERS) + self._state = round(value_converted) + elif self.unit_of_measurement == LENGTH_MILES: + value = getattr(vehicle_state, self._attribute) + value_converted = self.hass.config.units.length(value, + LENGTH_KILOMETERS) + self._state = round(value_converted) else: self._state = getattr(vehicle_state, self._attribute) diff --git a/homeassistant/components/sensor/dht.py b/homeassistant/components/sensor/dht.py index 387a555219d..a3af5631a9c 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -53,8 +53,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the DHT sensor.""" - # pylint: disable=import-error - import Adafruit_DHT + import Adafruit_DHT # pylint: disable=import-error SENSOR_TYPES[SENSOR_TEMPERATURE][1] = hass.config.units.temperature_unit available_sensors = { diff --git a/homeassistant/components/sensor/dnsip.py b/homeassistant/components/sensor/dnsip.py index 7b0d54cd934..c3ec5fd4ce2 100644 --- a/homeassistant/components/sensor/dnsip.py +++ b/homeassistant/components/sensor/dnsip.py @@ -87,8 +87,13 @@ class WanIpSensor(Entity): async def async_update(self): """Get the current DNS IP address for hostname.""" - response = await self.resolver.query(self.hostname, - self.querytype) + from aiodns.error import DNSError + try: + response = await self.resolver.query(self.hostname, + self.querytype) + except DNSError as err: + _LOGGER.warning("Exception while resolving host: %s", err) + response = None if response: self._state = response[0].host else: diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index d54959813f8..e3cf704d432 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['dsmr_parser==0.11'] +REQUIREMENTS = ['dsmr_parser==0.12'] CONF_DSMR_VERSION = 'dsmr_version' CONF_RECONNECT_INTERVAL = 'reconnect_interval' diff --git a/homeassistant/components/sensor/eddystone_temperature.py b/homeassistant/components/sensor/eddystone_temperature.py index 9e8dc33314a..ae3d498d30c 100644 --- a/homeassistant/components/sensor/eddystone_temperature.py +++ b/homeassistant/components/sensor/eddystone_temperature.py @@ -11,14 +11,14 @@ import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, TEMP_CELSIUS, STATE_UNKNOWN, EVENT_HOMEASSISTANT_STOP, - EVENT_HOMEASSISTANT_START) + CONF_NAME, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, + STATE_UNKNOWN, TEMP_CELSIUS) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['beacontools[scan]==1.2.3', 'construct==2.9.41'] +REQUIREMENTS = ['beacontools[scan]==1.2.3', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) @@ -43,12 +43,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Validate configuration, create devices and start monitoring thread.""" bt_device_id = config.get("bt_device_id") - beacons = config.get("beacons") + beacons = config.get(CONF_BEACONS) devices = [] for dev_name, properties in beacons.items(): - namespace = get_from_conf(properties, "namespace", 20) - instance = get_from_conf(properties, "instance", 12) + namespace = get_from_conf(properties, CONF_NAMESPACE, 20) + instance = get_from_conf(properties, CONF_INSTANCE, 12) name = properties.get(CONF_NAME, dev_name) if instance is None or namespace is None: @@ -138,8 +138,7 @@ class Monitor: additional_info['namespace'], additional_info['instance'], packet.temperature) - # pylint: disable=import-error - from beacontools import ( + from beacontools import ( # pylint: disable=import-error BeaconScanner, EddystoneFilter, EddystoneTLMFrame) device_filters = [EddystoneFilter(d.namespace, d.instance) for d in devices] diff --git a/homeassistant/components/sensor/elkm1.py b/homeassistant/components/sensor/elkm1.py new file mode 100644 index 00000000000..288f968b2f7 --- /dev/null +++ b/homeassistant/components/sensor/elkm1.py @@ -0,0 +1,227 @@ +""" +Support for control of ElkM1 sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.elkm1/ +""" +from homeassistant.components.elkm1 import ( + DOMAIN as ELK_DOMAIN, create_elk_entities, ElkEntity) + +DEPENDENCIES = [ELK_DOMAIN] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Create the Elk-M1 sensor platform.""" + if discovery_info is None: + return + + elk = hass.data[ELK_DOMAIN]['elk'] + entities = create_elk_entities( + hass, elk.counters, 'counter', ElkCounter, []) + entities = create_elk_entities( + hass, elk.keypads, 'keypad', ElkKeypad, entities) + entities = create_elk_entities( + hass, [elk.panel], 'panel', ElkPanel, entities) + entities = create_elk_entities( + hass, elk.settings, 'setting', ElkSetting, entities) + entities = create_elk_entities( + hass, elk.zones, 'zone', ElkZone, entities) + async_add_entities(entities, True) + + +def temperature_to_state(temperature, undefined_temperature): + """Convert temperature to a state.""" + return temperature if temperature > undefined_temperature else None + + +class ElkSensor(ElkEntity): + """Base representation of Elk-M1 sensor.""" + + def __init__(self, element, elk, elk_data): + """Initialize the base of all Elk sensors.""" + super().__init__(element, elk, elk_data) + self._state = None + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + +class ElkCounter(ElkSensor): + """Representation of an Elk-M1 Counter.""" + + @property + def icon(self): + """Icon to use in the frontend.""" + return 'mdi:numeric' + + def _element_changed(self, element, changeset): + self._state = self._element.value + + +class ElkKeypad(ElkSensor): + """Representation of an Elk-M1 Keypad.""" + + @property + def temperature_unit(self): + """Return the temperature unit.""" + return self._temperature_unit + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._temperature_unit + + @property + def icon(self): + """Icon to use in the frontend.""" + return 'mdi:thermometer-lines' + + @property + def device_state_attributes(self): + """Attributes of the sensor.""" + from elkm1_lib.util import username + + attrs = self.initial_attrs() + attrs['area'] = self._element.area + 1 + attrs['temperature'] = self._element.temperature + attrs['last_user_time'] = self._element.last_user_time.isoformat() + attrs['last_user'] = self._element.last_user + 1 + attrs['code'] = self._element.code + attrs['last_user_name'] = username(self._elk, self._element.last_user) + return attrs + + def _element_changed(self, element, changeset): + self._state = temperature_to_state(self._element.temperature, -40) + + async def async_added_to_hass(self): + """Register callback for ElkM1 changes and update entity state.""" + await super().async_added_to_hass() + self.hass.data[ELK_DOMAIN]['keypads'][ + self._element.index] = self.entity_id + + +class ElkPanel(ElkSensor): + """Representation of an Elk-M1 Panel.""" + + @property + def icon(self): + """Icon to use in the frontend.""" + return "mdi:home" + + @property + def device_state_attributes(self): + """Attributes of the sensor.""" + attrs = self.initial_attrs() + attrs['system_trouble_status'] = self._element.system_trouble_status + return attrs + + def _element_changed(self, element, changeset): + if self._elk.is_connected(): + self._state = 'Paused' if self._element.remote_programming_status \ + else 'Connected' + else: + self._state = 'Disconnected' + + +class ElkSetting(ElkSensor): + """Representation of an Elk-M1 Setting.""" + + @property + def icon(self): + """Icon to use in the frontend.""" + return 'mdi:numeric' + + def _element_changed(self, element, changeset): + self._state = self._element.value + + @property + def device_state_attributes(self): + """Attributes of the sensor.""" + from elkm1_lib.const import SettingFormat + attrs = self.initial_attrs() + attrs['value_format'] = SettingFormat( + self._element.value_format).name.lower() + return attrs + + +class ElkZone(ElkSensor): + """Representation of an Elk-M1 Zone.""" + + @property + def icon(self): + """Icon to use in the frontend.""" + from elkm1_lib.const import ZoneType + zone_icons = { + ZoneType.FIRE_ALARM.value: 'fire', + ZoneType.FIRE_VERIFIED.value: 'fire', + ZoneType.FIRE_SUPERVISORY.value: 'fire', + ZoneType.KEYFOB.value: 'key', + ZoneType.NON_ALARM.value: 'alarm-off', + ZoneType.MEDICAL_ALARM.value: 'medical-bag', + ZoneType.POLICE_ALARM.value: 'alarm-light', + ZoneType.POLICE_NO_INDICATION.value: 'alarm-light', + ZoneType.KEY_MOMENTARY_ARM_DISARM.value: 'power', + ZoneType.KEY_MOMENTARY_ARM_AWAY.value: 'power', + ZoneType.KEY_MOMENTARY_ARM_STAY.value: 'power', + ZoneType.KEY_MOMENTARY_DISARM.value: 'power', + ZoneType.KEY_ON_OFF.value: 'toggle-switch', + ZoneType.MUTE_AUDIBLES.value: 'volume-mute', + ZoneType.POWER_SUPERVISORY.value: 'power-plug', + ZoneType.TEMPERATURE.value: 'thermometer-lines', + ZoneType.ANALOG_ZONE.value: 'speedometer', + ZoneType.PHONE_KEY.value: 'phone-classic', + ZoneType.INTERCOM_KEY.value: 'deskphone' + } + return 'mdi:{}'.format( + zone_icons.get(self._element.definition, 'alarm-bell')) + + @property + def device_state_attributes(self): + """Attributes of the sensor.""" + from elkm1_lib.const import ( + ZoneLogicalStatus, ZonePhysicalStatus, ZoneType) + + attrs = self.initial_attrs() + attrs['physical_status'] = ZonePhysicalStatus( + self._element.physical_status).name.lower() + attrs['logical_status'] = ZoneLogicalStatus( + self._element.logical_status).name.lower() + attrs['definition'] = ZoneType( + self._element.definition).name.lower() + attrs['area'] = self._element.area + 1 + attrs['bypassed'] = self._element.bypassed + attrs['triggered_alarm'] = self._element.triggered_alarm + return attrs + + @property + def temperature_unit(self): + """Return the temperature unit.""" + from elkm1_lib.const import ZoneType + if self._element.definition == ZoneType.TEMPERATURE.value: + return self._temperature_unit + return None + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + from elkm1_lib.const import ZoneType + if self._element.definition == ZoneType.TEMPERATURE.value: + return self._temperature_unit + if self._element.definition == ZoneType.ANALOG_ZONE.value: + return 'V' + return None + + def _element_changed(self, element, changeset): + from elkm1_lib.const import ZoneLogicalStatus, ZoneType + from elkm1_lib.util import pretty_const + + if self._element.definition == ZoneType.TEMPERATURE.value: + self._state = temperature_to_state(self._element.temperature, -60) + elif self._element.definition == ZoneType.ANALOG_ZONE.value: + self._state = self._element.voltage + else: + self._state = pretty_const(ZoneLogicalStatus( + self._element.logical_status).name) diff --git a/homeassistant/components/sensor/fastdotcom.py b/homeassistant/components/sensor/fastdotcom.py index c6a56701f7c..761dc7c6a00 100644 --- a/homeassistant/components/sensor/fastdotcom.py +++ b/homeassistant/components/sensor/fastdotcom.py @@ -22,7 +22,6 @@ _LOGGER = logging.getLogger(__name__) CONF_SECOND = 'second' CONF_MINUTE = 'minute' CONF_HOUR = 'hour' -CONF_DAY = 'day' CONF_MANUAL = 'manual' ICON = 'mdi:speedometer' @@ -34,8 +33,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 59))]), vol.Optional(CONF_HOUR): vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 23))]), - vol.Optional(CONF_DAY): - vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(1, 31))]), vol.Optional(CONF_MANUAL, default=False): cv.boolean, }) @@ -109,8 +106,7 @@ class SpeedtestData: if not config.get(CONF_MANUAL): track_time_change( hass, self.update, second=config.get(CONF_SECOND), - minute=config.get(CONF_MINUTE), hour=config.get(CONF_HOUR), - day=config.get(CONF_DAY)) + minute=config.get(CONF_MINUTE), hour=config.get(CONF_HOUR)) def update(self, now): """Get the latest data from fast.com.""" diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 1c6e857b92b..e93795c3668 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -63,7 +63,6 @@ FILTER_SCHEMA = vol.Schema({ default=DEFAULT_PRECISION): vol.Coerce(int), }) -# pylint: disable=redefined-builtin FILTER_OUTLIER_SCHEMA = FILTER_SCHEMA.extend({ vol.Required(CONF_FILTER_NAME): FILTER_NAME_OUTLIER, vol.Optional(CONF_FILTER_WINDOW_SIZE, @@ -348,7 +347,7 @@ class RangeFilter(Filter): """ def __init__(self, entity, - lower_bound, upper_bound): + lower_bound=None, upper_bound=None): """Initialize Filter.""" super().__init__(FILTER_NAME_RANGE, entity=entity) self._lower_bound = lower_bound @@ -357,7 +356,8 @@ class RangeFilter(Filter): def _filter_state(self, new_state): """Implement the range filter.""" - if self._upper_bound and new_state.state > self._upper_bound: + if (self._upper_bound is not None + and new_state.state > self._upper_bound): self._stats_internal['erasures_up'] += 1 @@ -366,7 +366,8 @@ class RangeFilter(Filter): self._entity, new_state) new_state.state = self._upper_bound - elif self._lower_bound and new_state.state < self._lower_bound: + elif (self._lower_bound is not None + and new_state.state < self._lower_bound): self._stats_internal['erasures_low'] += 1 @@ -446,7 +447,8 @@ class TimeSMAFilter(Filter): variant (enum): type of argorithm used to connect discrete values """ - def __init__(self, window_size, precision, entity, type): + def __init__(self, window_size, precision, entity, + type): # pylint: disable=redefined-builtin """Initialize Filter.""" super().__init__(FILTER_NAME_TIME_SMA, window_size, precision, entity) self._time_window = window_size diff --git a/homeassistant/components/sensor/fints.py b/homeassistant/components/sensor/fints.py index 1704c13b5aa..e5dae70070b 100644 --- a/homeassistant/components/sensor/fints.py +++ b/homeassistant/components/sensor/fints.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_USERNAME, CONF_PIN, CONF_URL, CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['fints==0.2.1'] +REQUIREMENTS = ['fints==1.0.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/fritzbox_callmonitor.py b/homeassistant/components/sensor/fritzbox_callmonitor.py index c60d06da039..317416a15b8 100644 --- a/homeassistant/components/sensor/fritzbox_callmonitor.py +++ b/homeassistant/components/sensor/fritzbox_callmonitor.py @@ -239,8 +239,7 @@ class FritzBoxPhonebook: self.number_dict = None self.prefixes = prefixes or [] - # pylint: disable=import-error - import fritzconnection as fc + import fritzconnection as fc # pylint: disable=import-error # Establish a connection to the FRITZ!Box. self.fph = fc.FritzPhonebook( address=self.host, user=self.username, password=self.password) diff --git a/homeassistant/components/sensor/geo_rss_events.py b/homeassistant/components/sensor/geo_rss_events.py index 5085e113e92..60ae9730d80 100644 --- a/homeassistant/components/sensor/geo_rss_events.py +++ b/homeassistant/components/sensor/geo_rss_events.py @@ -16,7 +16,8 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_RADIUS, CONF_URL) + STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT, CONF_NAME, + CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_URL) from homeassistant.helpers.entity import Entity REQUIREMENTS = ['georss_client==0.3'] @@ -40,6 +41,8 @@ SCAN_INTERVAL = timedelta(minutes=5) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_CATEGORIES, default=[]): @@ -51,8 +54,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the GeoRSS component.""" - home_latitude = hass.config.latitude - home_longitude = hass.config.longitude + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) url = config.get(CONF_URL) radius_in_km = config.get(CONF_RADIUS) name = config.get(CONF_NAME) @@ -60,18 +63,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) _LOGGER.debug("latitude=%s, longitude=%s, url=%s, radius=%s", - home_latitude, home_longitude, url, radius_in_km) + latitude, longitude, url, radius_in_km) # Create all sensors based on categories. devices = [] if not categories: - device = GeoRssServiceSensor((home_latitude, home_longitude), url, + device = GeoRssServiceSensor((latitude, longitude), url, radius_in_km, None, name, unit_of_measurement) devices.append(device) else: for category in categories: - device = GeoRssServiceSensor((home_latitude, home_longitude), url, + device = GeoRssServiceSensor((latitude, longitude), url, radius_in_km, category, name, unit_of_measurement) devices.append(device) @@ -81,7 +84,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class GeoRssServiceSensor(Entity): """Representation of a Sensor.""" - def __init__(self, home_coordinates, url, radius, category, service_name, + def __init__(self, coordinates, url, radius, category, service_name, unit_of_measurement): """Initialize the sensor.""" self._category = category @@ -90,7 +93,7 @@ class GeoRssServiceSensor(Entity): self._state_attributes = None self._unit_of_measurement = unit_of_measurement from georss_client.generic_feed import GenericFeed - self._feed = GenericFeed(home_coordinates, url, filter_radius=radius, + self._feed = GenericFeed(coordinates, url, filter_radius=radius, filter_categories=None if not category else [category]) @@ -145,3 +148,4 @@ class GeoRssServiceSensor(Entity): # If no events were found due to an error then just set state to # zero. self._state = 0 + self._state_attributes = {} diff --git a/homeassistant/components/sensor/gitlab_ci.py b/homeassistant/components/sensor/gitlab_ci.py index ceb5f75cace..1e55a7d6997 100644 --- a/homeassistant/components/sensor/gitlab_ci.py +++ b/homeassistant/components/sensor/gitlab_ci.py @@ -1,4 +1,9 @@ -"""Module for retrieving latest GitLab CI job information.""" +""" +Sensor for retrieving latest GitLab CI job information. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.gitlab_ci/ +""" from datetime import timedelta import logging @@ -11,38 +16,41 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -CONF_GITLAB_ID = 'gitlab_id' -CONF_ATTRIBUTION = "Information provided by https://gitlab.com/" - -ICON_HAPPY = 'mdi:emoticon-happy' -ICON_SAD = 'mdi:emoticon-happy' -ICON_OTHER = 'mdi:git' - -ATTR_BUILD_ID = 'build id' -ATTR_BUILD_STATUS = 'build_status' -ATTR_BUILD_STARTED = 'build_started' -ATTR_BUILD_FINISHED = 'build_finished' -ATTR_BUILD_DURATION = 'build_duration' -ATTR_BUILD_COMMIT_ID = 'commit id' -ATTR_BUILD_COMMIT_DATE = 'commit date' -ATTR_BUILD_BRANCH = 'build branch' - -SCAN_INTERVAL = timedelta(seconds=300) +REQUIREMENTS = ['python-gitlab==1.6.0'] _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_TOKEN): cv.string, - vol.Required(CONF_GITLAB_ID): cv.string, - vol.Optional(CONF_NAME, default='GitLab CI Status'): cv.string, - vol.Optional(CONF_URL, default='https://gitlab.com'): cv.string -}) +ATTR_BUILD_BRANCH = 'build branch' +ATTR_BUILD_COMMIT_DATE = 'commit date' +ATTR_BUILD_COMMIT_ID = 'commit id' +ATTR_BUILD_DURATION = 'build_duration' +ATTR_BUILD_FINISHED = 'build_finished' +ATTR_BUILD_ID = 'build id' +ATTR_BUILD_STARTED = 'build_started' +ATTR_BUILD_STATUS = 'build_status' -REQUIREMENTS = ['python-gitlab==1.6.0'] +CONF_ATTRIBUTION = "Information provided by https://gitlab.com/" +CONF_GITLAB_ID = 'gitlab_id' + +DEFAULT_NAME = 'GitLab CI Status' +DEFAULT_URL = 'https://gitlab.com' + +ICON_HAPPY = 'mdi:emoticon-happy' +ICON_OTHER = 'mdi:git' +ICON_SAD = 'mdi:emoticon-happy' + +SCAN_INTERVAL = timedelta(seconds=300) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_GITLAB_ID): cv.string, + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_URL, default=DEFAULT_URL): cv.string +}) def setup_platform(hass, config, add_entities, discovery_info=None): - """Sensor platform setup.""" + """Set up the GitLab sensor platform.""" _name = config.get(CONF_NAME) _interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) _url = config.get(CONF_URL) @@ -58,10 +66,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class GitLabSensor(Entity): - """Representation of a Sensor.""" + """Representation of a GitLab sensor.""" def __init__(self, gitlab_data, name): - """Initialize the sensor.""" + """Initialize the GitLab sensor.""" self._available = False self._state = None self._started_at = None diff --git a/homeassistant/components/sensor/haveibeenpwned.py b/homeassistant/components/sensor/haveibeenpwned.py index 9428eaea00e..4d651ea81c7 100644 --- a/homeassistant/components/sensor/haveibeenpwned.py +++ b/homeassistant/components/sensor/haveibeenpwned.py @@ -42,24 +42,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): devices = [] for email in emails: - devices.append(HaveIBeenPwnedSensor(data, hass, email)) + devices.append(HaveIBeenPwnedSensor(data, email)) add_entities(devices) - # To make sure we get initial data for the sensors ignoring the normal - # throttle of 15 minutes but using an update throttle of 5 seconds - for sensor in devices: - sensor.update_nothrottle() - class HaveIBeenPwnedSensor(Entity): """Implementation of a HaveIBeenPwned sensor.""" - def __init__(self, data, hass, email): + def __init__(self, data, email): """Initialize the HaveIBeenPwned sensor.""" self._state = None self._data = data - self._hass = hass self._email = email self._unit_of_measurement = "Breaches" @@ -95,6 +89,12 @@ class HaveIBeenPwnedSensor(Entity): return val + async def async_added_to_hass(self): + """Get initial data.""" + # To make sure we get initial data for the sensors ignoring the normal + # throttle of 15 minutes but using an update throttle of 5 seconds + self.hass.async_add_executor_job(self.update_nothrottle) + def update_nothrottle(self, dummy=None): """Update sensor without throttle.""" self._data.update_no_throttle() @@ -106,13 +106,12 @@ class HaveIBeenPwnedSensor(Entity): # normal using update if self._email not in self._data.data: track_point_in_time( - self._hass, self.update_nothrottle, + self.hass, self.update_nothrottle, dt_util.now() + MIN_TIME_BETWEEN_FORCED_UPDATES) return - if self._email in self._data.data: - self._state = len(self._data.data[self._email]) - self.schedule_update_ha_state() + self._state = len(self._data.data[self._email]) + self.schedule_update_ha_state() def update(self): """Update data and see if it contains data for our email.""" diff --git a/homeassistant/components/sensor/homematic.py b/homeassistant/components/sensor/homematic.py index 8495286c143..26fa76d94a9 100644 --- a/homeassistant/components/sensor/homematic.py +++ b/homeassistant/components/sensor/homematic.py @@ -48,6 +48,10 @@ HM_UNIT_HA_CAST = { 'GAS_POWER': 'm3', 'GAS_ENERGY_COUNTER': 'm3', 'LUX': 'lx', + 'CURRENT_ILLUMINATION': 'lx', + 'AVERAGE_ILLUMINATION': 'lx', + 'LOWEST_ILLUMINATION': 'lx', + 'HIGHEST_ILLUMINATION': 'lx', 'RAIN_COUNTER': 'mm', 'WIND_SPEED': 'km/h', 'WIND_DIRECTION': '°', @@ -64,6 +68,10 @@ HM_ICON_HA_CAST = { 'TEMPERATURE': 'mdi:thermometer', 'ACTUAL_TEMPERATURE': 'mdi:thermometer', 'LUX': 'mdi:weather-sunny', + 'CURRENT_ILLUMINATION': 'mdi:weather-sunny', + 'AVERAGE_ILLUMINATION': 'mdi:weather-sunny', + 'LOWEST_ILLUMINATION': 'mdi:weather-sunny', + 'HIGHEST_ILLUMINATION': 'mdi:weather-sunny', 'BRIGHTNESS': 'mdi:invert-colors', 'POWER': 'mdi:flash-red-eye', 'CURRENT': 'mdi:flash-red-eye', diff --git a/homeassistant/components/sensor/htu21d.py b/homeassistant/components/sensor/htu21d.py index ae2555f57f9..4f8665b2011 100644 --- a/homeassistant/components/sensor/htu21d.py +++ b/homeassistant/components/sensor/htu21d.py @@ -38,12 +38,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=import-error async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the HTU21D sensor.""" - import smbus - from i2csense.htu21d import HTU21D + import smbus # pylint: disable=import-error + from i2csense.htu21d import HTU21D # pylint: disable=import-error name = config.get(CONF_NAME) bus_number = config.get(CONF_I2C_BUS) diff --git a/homeassistant/components/sensor/huawei_lte.py b/homeassistant/components/sensor/huawei_lte.py index f5a21999ab8..5ff5f9b38ae 100644 --- a/homeassistant/components/sensor/huawei_lte.py +++ b/homeassistant/components/sensor/huawei_lte.py @@ -24,11 +24,14 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['huawei_lte'] -DEFAULT_NAME_TEMPLATE = 'Huawei {}: {}' +DEFAULT_NAME_TEMPLATE = 'Huawei {} {}' DEFAULT_SENSORS = [ "device_information.WanIPAddress", + "device_signal.rsrq", + "device_signal.rsrp", "device_signal.rssi", + "device_signal.sinr", ] SENSOR_META = { @@ -43,6 +46,26 @@ SENSOR_META = { name="WAN IPv6 address", icon="mdi:ip", ), + "device_signal.band": dict( + name="Band", + ), + "device_signal.cell_id": dict( + name="Cell ID", + ), + "device_signal.lac": dict( + name="LAC", + ), + "device_signal.mode": dict( + name="Mode", + formatter=lambda x: ({ + '0': '2G', + '2': '3G', + '7': '4G', + }.get(x, 'Unknown'), None), + ), + "device_signal.pci": dict( + name="PCI", + ), "device_signal.rsrq": dict( name="RSRQ", # http://www.lte-anbieter.info/technik/rsrq.php @@ -99,6 +122,22 @@ def setup_platform( add_entities(sensors, True) +def format_default(value): + """Format value.""" + unit = None + if value is not None: + # Clean up value and infer unit, e.g. -71dBm, 15 dB + match = re.match( + r"(?P.+?)\s*(?P[a-zA-Z]+)\s*$", str(value)) + if match: + try: + value = float(match.group("value")) + unit = match.group("unit") + except ValueError: + pass + return value, unit + + @attr.s class HuaweiLteSensor(Entity): """Huawei LTE sensor entity.""" @@ -114,8 +153,8 @@ class HuaweiLteSensor(Entity): def unique_id(self) -> str: """Return unique ID for sensor.""" return "{}_{}".format( - self.path, self.data["device_information.SerialNumber"], + ".".join(self.path), ) @property @@ -147,23 +186,14 @@ class HuaweiLteSensor(Entity): """Update state.""" self.data.update() - unit = None try: value = self.data[self.path] except KeyError: _LOGGER.warning("%s not in data", self.path) value = None - if value is not None: - # Clean up value and infer unit, e.g. -71dBm, 15 dB - match = re.match( - r"(?P.+?)\s*(?P[a-zA-Z]+)\s*$", str(value)) - if match: - try: - value = float(match.group("value")) - unit = match.group("unit") - except ValueError: - pass + formatter = self.meta.get("formatter") + if not callable(formatter): + formatter = format_default - self._state = value - self._unit = unit + self._state, self._unit = formatter(value) diff --git a/homeassistant/components/sensor/jewish_calendar.py b/homeassistant/components/sensor/jewish_calendar.py index 1de45d6145e..e1225b8f25d 100644 --- a/homeassistant/components/sensor/jewish_calendar.py +++ b/homeassistant/components/sensor/jewish_calendar.py @@ -4,6 +4,7 @@ Platform to retrieve Jewish calendar information for Home Assistant. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.jewish_calendar/ """ +from datetime import timedelta import logging import voluptuous as vol @@ -14,7 +15,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util -REQUIREMENTS = ['hdate==0.6.3'] +REQUIREMENTS = ['hdate==0.6.5'] _LOGGER = logging.getLogger(__name__) @@ -107,16 +108,20 @@ class JewishCalSensor(Entity): import hdate today = dt_util.now().date() + upcoming_saturday = today + timedelta((12 - today.weekday()) % 7) date = hdate.HDate( today, diaspora=self.diaspora, hebrew=self._hebrew) + upcoming_shabbat = hdate.HDate( + upcoming_saturday, diaspora=self.diaspora, hebrew=self._hebrew) if self.type == 'date': self._state = hdate.date.get_hebrew_date( date.h_day, date.h_month, date.h_year, hebrew=self._hebrew) elif self.type == 'weekly_portion': self._state = hdate.date.get_parashe( - date.get_reading(self.diaspora), hebrew=self._hebrew) + upcoming_shabbat.get_reading(self.diaspora), + hebrew=self._hebrew) elif self.type == 'holiday_name': try: description = next( diff --git a/homeassistant/components/sensor/knx.py b/homeassistant/components/sensor/knx.py index ec506189c12..c096e15192d 100644 --- a/homeassistant/components/sensor/knx.py +++ b/homeassistant/components/sensor/knx.py @@ -42,7 +42,7 @@ def async_add_entities_discovery(hass, discovery_info, async_add_entities): entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] - entities.append(KNXSensor(hass, device)) + entities.append(KNXSensor(device)) async_add_entities(entities) @@ -56,17 +56,15 @@ def async_add_entities_config(hass, config, async_add_entities): group_address=config.get(CONF_ADDRESS), value_type=config.get(CONF_TYPE)) hass.data[DATA_KNX].xknx.devices.add(sensor) - async_add_entities([KNXSensor(hass, sensor)]) + async_add_entities([KNXSensor(sensor)]) class KNXSensor(Entity): """Representation of a KNX sensor.""" - def __init__(self, hass, device): + def __init__(self, device): """Initialize of a KNX sensor.""" self.device = device - self.hass = hass - self.async_register_callbacks() @callback def async_register_callbacks(self): @@ -76,6 +74,10 @@ class KNXSensor(Entity): await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) + async def async_added_to_hass(self): + """Store register state change callback.""" + self.async_register_callbacks() + @property def name(self): """Return the name of the KNX device.""" diff --git a/homeassistant/components/sensor/linky.py b/homeassistant/components/sensor/linky.py index 83a6d793085..316da010ae4 100644 --- a/homeassistant/components/sensor/linky.py +++ b/homeassistant/components/sensor/linky.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pylinky==0.1.6'] +REQUIREMENTS = ['pylinky==0.1.8'] _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=10) @@ -37,11 +37,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): from pylinky.client import LinkyClient, PyLinkyError client = LinkyClient(username, password, None, timeout) - try: client.fetch_data() except PyLinkyError as exp: _LOGGER.error(exp) + client.close_session() return devices = [LinkySensor('Linky', client)] @@ -80,6 +80,7 @@ class LinkySensor(Entity): self._client.fetch_data() except PyLinkyError as exp: _LOGGER.error(exp) + self._client.close_session() return _LOGGER.debug(json.dumps(self._client.get_data(), indent=2)) diff --git a/homeassistant/components/sensor/mold_indicator.py b/homeassistant/components/sensor/mold_indicator.py index e5794ab1314..2a250f0e63d 100644 --- a/homeassistant/components/sensor/mold_indicator.py +++ b/homeassistant/components/sensor/mold_indicator.py @@ -9,12 +9,15 @@ import math import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant import util -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import track_state_change +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.core import callback from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_NAME) + ATTR_UNIT_OF_MEASUREMENT, EVENT_HOMEASSISTANT_START, STATE_UNKNOWN, + TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_NAME) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_state_change + import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -41,7 +44,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): """Set up MoldIndicator sensor.""" name = config.get(CONF_NAME, DEFAULT_NAME) indoor_temp_sensor = config.get(CONF_INDOOR_TEMP) @@ -49,16 +53,16 @@ def setup_platform(hass, config, add_entities, discovery_info=None): indoor_humidity_sensor = config.get(CONF_INDOOR_HUMIDITY) calib_factor = config.get(CONF_CALIBRATION_FACTOR) - add_entities([MoldIndicator( - hass, name, indoor_temp_sensor, outdoor_temp_sensor, - indoor_humidity_sensor, calib_factor)], True) + async_add_entities([MoldIndicator( + name, hass.config.units.is_metric, indoor_temp_sensor, + outdoor_temp_sensor, indoor_humidity_sensor, calib_factor)], False) class MoldIndicator(Entity): """Represents a MoldIndication sensor.""" - def __init__(self, hass, name, indoor_temp_sensor, outdoor_temp_sensor, - indoor_humidity_sensor, calib_factor): + def __init__(self, name, is_metric, indoor_temp_sensor, + outdoor_temp_sensor, indoor_humidity_sensor, calib_factor): """Initialize the sensor.""" self._state = None self._name = name @@ -66,7 +70,11 @@ class MoldIndicator(Entity): self._indoor_humidity_sensor = indoor_humidity_sensor self._outdoor_temp_sensor = outdoor_temp_sensor self._calib_factor = calib_factor - self._is_metric = hass.config.units.is_metric + self._is_metric = is_metric + self._available = False + self._entities = set([self._indoor_temp_sensor, + self._indoor_humidity_sensor, + self._outdoor_temp_sensor]) self._dewpoint = None self._indoor_temp = None @@ -74,34 +82,85 @@ class MoldIndicator(Entity): self._indoor_hum = None self._crit_temp = None - track_state_change(hass, indoor_temp_sensor, self._sensor_changed) - track_state_change(hass, outdoor_temp_sensor, self._sensor_changed) - track_state_change(hass, indoor_humidity_sensor, self._sensor_changed) + async def async_added_to_hass(self): + """Register callbacks.""" + @callback + def mold_indicator_sensors_state_listener(entity, old_state, + new_state): + """Handle for state changes for dependent sensors.""" + _LOGGER.debug("Sensor state change for %s that had old state %s " + "and new state %s", entity, old_state, new_state) - # Read initial state - indoor_temp = hass.states.get(indoor_temp_sensor) - outdoor_temp = hass.states.get(outdoor_temp_sensor) - indoor_hum = hass.states.get(indoor_humidity_sensor) + if self._update_sensor(entity, old_state, new_state): + self.async_schedule_update_ha_state(True) - if indoor_temp: - self._indoor_temp = MoldIndicator._update_temp_sensor(indoor_temp) + @callback + def mold_indicator_startup(event): + """Add listeners and get 1st state.""" + _LOGGER.debug("Startup for %s", self.entity_id) - if outdoor_temp: - self._outdoor_temp = MoldIndicator._update_temp_sensor( - outdoor_temp) + async_track_state_change(self.hass, self._entities, + mold_indicator_sensors_state_listener) - if indoor_hum: - self._indoor_hum = MoldIndicator._update_hum_sensor(indoor_hum) + # Read initial state + indoor_temp = self.hass.states.get(self._indoor_temp_sensor) + outdoor_temp = self.hass.states.get(self._outdoor_temp_sensor) + indoor_hum = self.hass.states.get(self._indoor_humidity_sensor) + + schedule_update = self._update_sensor(self._indoor_temp_sensor, + None, indoor_temp) + + schedule_update = False if not self._update_sensor( + self._outdoor_temp_sensor, None, outdoor_temp) else\ + schedule_update + + schedule_update = False if not self._update_sensor( + self._indoor_humidity_sensor, None, indoor_hum) else\ + schedule_update + + if schedule_update: + self.async_schedule_update_ha_state(True) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, mold_indicator_startup) + + def _update_sensor(self, entity, old_state, new_state): + """Update information based on new sensor states.""" + _LOGGER.debug("Sensor update for %s", entity) + if new_state is None: + return False + + # If old_state is not set and new state is unknown then it means + # that the sensor just started up + if old_state is None and new_state.state == STATE_UNKNOWN: + return False + + if entity == self._indoor_temp_sensor: + self._indoor_temp = MoldIndicator._update_temp_sensor(new_state) + elif entity == self._outdoor_temp_sensor: + self._outdoor_temp = MoldIndicator._update_temp_sensor(new_state) + elif entity == self._indoor_humidity_sensor: + self._indoor_hum = MoldIndicator._update_hum_sensor(new_state) + + return True @staticmethod def _update_temp_sensor(state): """Parse temperature sensor value.""" + _LOGGER.debug("Updating temp sensor with value %s", state.state) + + # Return an error if the sensor change its state to Unknown. + if state.state == STATE_UNKNOWN: + _LOGGER.error("Unable to parse temperature sensor %s with state:" + " %s", state.entity_id, state.state) + return None + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) temp = util.convert(state.state, float) if temp is None: - _LOGGER.error('Unable to parse sensor temperature: %s', - state.state) + _LOGGER.error("Unable to parse temperature sensor %s with state:" + " %s", state.entity_id, state.state) return None # convert to celsius if necessary @@ -109,56 +168,62 @@ class MoldIndicator(Entity): return util.temperature.fahrenheit_to_celsius(temp) if unit == TEMP_CELSIUS: return temp - _LOGGER.error("Temp sensor has unsupported unit: %s (allowed: %s, " - "%s)", unit, TEMP_CELSIUS, TEMP_FAHRENHEIT) + _LOGGER.error("Temp sensor %s has unsupported unit: %s (allowed: %s, " + "%s)", state.entity_id, unit, TEMP_CELSIUS, + TEMP_FAHRENHEIT) return None @staticmethod def _update_hum_sensor(state): """Parse humidity sensor value.""" + _LOGGER.debug("Updating humidity sensor with value %s", state.state) + + # Return an error if the sensor change its state to Unknown. + if state.state == STATE_UNKNOWN: + _LOGGER.error('Unable to parse humidity sensor %s, state: %s', + state.entity_id, state.state) + return None + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) hum = util.convert(state.state, float) if hum is None: - _LOGGER.error('Unable to parse sensor humidity: %s', - state.state) + _LOGGER.error("Unable to parse humidity sensor %s, state: %s", + state.entity_id, state.state) return None if unit != '%': - _LOGGER.error("Humidity sensor has unsupported unit: %s %s", - unit, " (allowed: %)") + _LOGGER.error("Humidity sensor %s has unsupported unit: %s %s", + state.entity_id, unit, " (allowed: %)") + return None if hum > 100 or hum < 0: - _LOGGER.error("Humidity sensor out of range: %s %s", hum, - " (allowed: 0-100%)") + _LOGGER.error("Humidity sensor %s is out of range: %s %s", + state.entity_id, hum, "(allowed: 0-100%)") + return None return hum - def update(self): + async def async_update(self): """Calculate latest state.""" + _LOGGER.debug("Update state for %s", self.entity_id) # check all sensors if None in (self._indoor_temp, self._indoor_hum, self._outdoor_temp): + self._available = False + self._dewpoint = None + self._crit_temp = None return # re-calculate dewpoint and mold indicator self._calc_dewpoint() self._calc_moldindicator() - - def _sensor_changed(self, entity_id, old_state, new_state): - """Handle sensor state changes.""" - if new_state is None: - return - - if entity_id == self._indoor_temp_sensor: - self._indoor_temp = MoldIndicator._update_temp_sensor(new_state) - elif entity_id == self._outdoor_temp_sensor: - self._outdoor_temp = MoldIndicator._update_temp_sensor(new_state) - elif entity_id == self._indoor_humidity_sensor: - self._indoor_hum = MoldIndicator._update_hum_sensor(new_state) - - self.update() - self.schedule_update_ha_state() + if self._state is None: + self._available = False + self._dewpoint = None + self._crit_temp = None + else: + self._available = True def _calc_dewpoint(self): """Calculate the dewpoint for the indoor air.""" @@ -183,6 +248,8 @@ class MoldIndicator(Entity): " calibration-factor: %s", self._dewpoint, self._calib_factor) self._state = None + self._available = False + self._crit_temp = None return # first calculate the approximate temperature at the calibration point @@ -232,6 +299,11 @@ class MoldIndicator(Entity): """Return the state of the entity.""" return self._state + @property + def available(self): + """Return the availability of this sensor.""" + return self._available + @property def device_state_attributes(self): """Return the state attributes.""" @@ -240,9 +312,16 @@ class MoldIndicator(Entity): ATTR_DEWPOINT: self._dewpoint, ATTR_CRITICAL_TEMP: self._crit_temp, } + + dewpoint = util.temperature.celsius_to_fahrenheit(self._dewpoint) \ + if self._dewpoint is not None else None + + crit_temp = util.temperature.celsius_to_fahrenheit(self._crit_temp) \ + if self._crit_temp is not None else None + return { ATTR_DEWPOINT: - util.temperature.celsius_to_fahrenheit(self._dewpoint), + dewpoint, ATTR_CRITICAL_TEMP: - util.temperature.celsius_to_fahrenheit(self._crit_temp), + crit_temp, } diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index fe0b77b2024..225ed07a622 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -16,12 +16,12 @@ from homeassistant.components import sensor from homeassistant.components.mqtt import ( ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, - MqttAvailability, MqttDiscoveryUpdate) + MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo) from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW from homeassistant.components.sensor import DEVICE_CLASSES_SCHEMA from homeassistant.const import ( CONF_FORCE_UPDATE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, - CONF_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_DEVICE_CLASS) + CONF_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_DEVICE_CLASS, CONF_DEVICE) from homeassistant.helpers.entity import Entity from homeassistant.components import mqtt import homeassistant.helpers.config_validation as cv @@ -51,6 +51,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ # Integrations shouldn't never expose unique_id through configuration # this here is an exception because MQTT is a msg transport, not a protocol vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @@ -95,22 +96,25 @@ async def _async_setup_entity(hass: HomeAssistantType, config: ConfigType, config.get(CONF_AVAILABILITY_TOPIC), config.get(CONF_PAYLOAD_AVAILABLE), config.get(CONF_PAYLOAD_NOT_AVAILABLE), + config.get(CONF_DEVICE), discovery_hash, )]) -class MqttSensor(MqttAvailability, MqttDiscoveryUpdate, Entity): +class MqttSensor(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, + Entity): """Representation of a sensor that can be updated using MQTT.""" def __init__(self, name, state_topic, qos, unit_of_measurement, force_update, expire_after, icon, device_class: Optional[str], value_template, json_attributes, unique_id: Optional[str], availability_topic, payload_available, payload_not_available, - discovery_hash): + device_config: Optional[ConfigType], discovery_hash): """Initialize the sensor.""" MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) MqttDiscoveryUpdate.__init__(self, discovery_hash) + MqttEntityDeviceInfo.__init__(self, device_config) self._state = STATE_UNKNOWN self._name = name self._state_topic = state_topic diff --git a/homeassistant/components/sensor/octoprint.py b/homeassistant/components/sensor/octoprint.py index d42828c9f55..8170b97c4c8 100644 --- a/homeassistant/components/sensor/octoprint.py +++ b/homeassistant/components/sensor/octoprint.py @@ -7,42 +7,28 @@ https://home-assistant.io/components/sensor.octoprint/ import logging import requests -import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - TEMP_CELSIUS, CONF_NAME, CONF_MONITORED_CONDITIONS) +from homeassistant.components.octoprint import (SENSOR_TYPES, + DOMAIN as COMPONENT_DOMAIN) +from homeassistant.const import (TEMP_CELSIUS) from homeassistant.helpers.entity import Entity -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['octoprint'] -DOMAIN = "octoprint" -DEFAULT_NAME = 'OctoPrint' NOTIFICATION_ID = 'octoprint_notification' NOTIFICATION_TITLE = 'OctoPrint sensor setup error' -SENSOR_TYPES = { - 'Temperatures': ['printer', 'temperature', '*', TEMP_CELSIUS], - 'Current State': ['printer', 'state', 'text', None], - 'Job Percentage': ['job', 'progress', 'completion', '%'], - 'Time Remaining': ['job', 'progress', 'printTimeLeft', 'seconds'], - 'Time Elapsed': ['job', 'progress', 'printTime', 'seconds'], -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, -}) - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the available OctoPrint sensors.""" - octoprint_api = hass.data[DOMAIN]["api"] - name = config.get(CONF_NAME) - monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) + if discovery_info is None: + return + + name = discovery_info['name'] + base_url = discovery_info['base_url'] + monitored_conditions = discovery_info['sensors'] + octoprint_api = hass.data[COMPONENT_DOMAIN][base_url] tools = octoprint_api.get_tools() if "Temperatures" in monitored_conditions: @@ -72,7 +58,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): new_sensor = OctoPrintSensor( octoprint_api, octo_type, SENSOR_TYPES[octo_type][2], name, SENSOR_TYPES[octo_type][3], SENSOR_TYPES[octo_type][0], - SENSOR_TYPES[octo_type][1]) + SENSOR_TYPES[octo_type][1], None, SENSOR_TYPES[octo_type][4]) devices.append(new_sensor) add_entities(devices, True) @@ -81,7 +67,7 @@ class OctoPrintSensor(Entity): """Representation of an OctoPrint sensor.""" def __init__(self, api, condition, sensor_type, sensor_name, unit, - endpoint, group, tool=None): + endpoint, group, tool=None, icon=None): """Initialize a new OctoPrint sensor.""" self.sensor_name = sensor_name if tool is None: @@ -96,6 +82,7 @@ class OctoPrintSensor(Entity): self.api_endpoint = endpoint self.api_group = group self.api_tool = tool + self._icon = icon _LOGGER.debug("Created OctoPrint sensor %r", self) @property @@ -128,3 +115,8 @@ class OctoPrintSensor(Entity): except requests.exceptions.ConnectionError: # Error calling the api, already logged in api.update() return + + @property + def icon(self): + """Icon to use in the frontend.""" + return self._icon diff --git a/homeassistant/components/sensor/opentherm_gw.py b/homeassistant/components/sensor/opentherm_gw.py new file mode 100644 index 00000000000..9ae557654ce --- /dev/null +++ b/homeassistant/components/sensor/opentherm_gw.py @@ -0,0 +1,211 @@ +""" +Support for OpenTherm Gateway sensors. + +For more details about this platform, please refer to the documentation at +http://home-assistant.io/components/sensor.opentherm_gw/ +""" +import logging + +from homeassistant.components.opentherm_gw import ( + DATA_GW_VARS, DATA_OPENTHERM_GW, SIGNAL_OPENTHERM_GW_UPDATE) +from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity, async_generate_entity_id + +UNIT_BAR = 'bar' +UNIT_HOUR = 'h' +UNIT_KW = 'kW' +UNIT_L_MIN = 'L/min' +UNIT_PERCENT = '%' + +DEPENDENCIES = ['opentherm_gw'] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the OpenTherm Gateway sensors.""" + if discovery_info is None: + return + gw_vars = hass.data[DATA_OPENTHERM_GW][DATA_GW_VARS] + sensor_info = { + # [device_class, unit, friendly_name] + gw_vars.DATA_CONTROL_SETPOINT: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Control Setpoint"], + gw_vars.DATA_MASTER_MEMBERID: [None, None, "Thermostat Member ID"], + gw_vars.DATA_SLAVE_MEMBERID: [None, None, "Boiler Member ID"], + gw_vars.DATA_SLAVE_OEM_FAULT: [None, None, "Boiler OEM Fault Code"], + gw_vars.DATA_COOLING_CONTROL: [ + None, UNIT_PERCENT, "Cooling Control Signal"], + gw_vars.DATA_CONTROL_SETPOINT_2: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Control Setpoint 2"], + gw_vars.DATA_ROOM_SETPOINT_OVRD: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Room Setpoint Override"], + gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD: [ + None, UNIT_PERCENT, "Boiler Maximum Relative Modulation"], + gw_vars.DATA_SLAVE_MAX_CAPACITY: [ + None, UNIT_KW, "Boiler Maximum Capacity"], + gw_vars.DATA_SLAVE_MIN_MOD_LEVEL: [ + None, UNIT_PERCENT, "Boiler Minimum Modulation Level"], + gw_vars.DATA_ROOM_SETPOINT: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Room Setpoint"], + gw_vars.DATA_REL_MOD_LEVEL: [ + None, UNIT_PERCENT, "Relative Modulation Level"], + gw_vars.DATA_CH_WATER_PRESS: [ + None, UNIT_BAR, "Central Heating Water Pressure"], + gw_vars.DATA_DHW_FLOW_RATE: [None, UNIT_L_MIN, "Hot Water Flow Rate"], + gw_vars.DATA_ROOM_SETPOINT_2: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Room Setpoint 2"], + gw_vars.DATA_ROOM_TEMP: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Room Temperature"], + gw_vars.DATA_CH_WATER_TEMP: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + "Central Heating Water Temperature"], + gw_vars.DATA_DHW_TEMP: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Hot Water Temperature"], + gw_vars.DATA_OUTSIDE_TEMP: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Outside Temperature"], + gw_vars.DATA_RETURN_WATER_TEMP: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + "Return Water Temperature"], + gw_vars.DATA_SOLAR_STORAGE_TEMP: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + "Solar Storage Temperature"], + gw_vars.DATA_SOLAR_COLL_TEMP: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + "Solar Collector Temperature"], + gw_vars.DATA_CH_WATER_TEMP_2: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + "Central Heating 2 Water Temperature"], + gw_vars.DATA_DHW_TEMP_2: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Hot Water 2 Temperature"], + gw_vars.DATA_EXHAUST_TEMP: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Exhaust Temperature"], + gw_vars.DATA_SLAVE_DHW_MAX_SETP: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + "Hot Water Maximum Setpoint"], + gw_vars.DATA_SLAVE_DHW_MIN_SETP: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + "Hot Water Minimum Setpoint"], + gw_vars.DATA_SLAVE_CH_MAX_SETP: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + "Boiler Maximum Central Heating Setpoint"], + gw_vars.DATA_SLAVE_CH_MIN_SETP: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + "Boiler Minimum Central Heating Setpoint"], + gw_vars.DATA_DHW_SETPOINT: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Hot Water Setpoint"], + gw_vars.DATA_MAX_CH_SETPOINT: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + "Maximum Central Heating Setpoint"], + gw_vars.DATA_OEM_DIAG: [None, None, "OEM Diagnostic Code"], + gw_vars.DATA_TOTAL_BURNER_STARTS: [ + None, None, "Total Burner Starts"], + gw_vars.DATA_CH_PUMP_STARTS: [ + None, None, "Central Heating Pump Starts"], + gw_vars.DATA_DHW_PUMP_STARTS: [None, None, "Hot Water Pump Starts"], + gw_vars.DATA_DHW_BURNER_STARTS: [ + None, None, "Hot Water Burner Starts"], + gw_vars.DATA_TOTAL_BURNER_HOURS: [ + None, UNIT_HOUR, "Total Burner Hours"], + gw_vars.DATA_CH_PUMP_HOURS: [ + None, UNIT_HOUR, "Central Heating Pump Hours"], + gw_vars.DATA_DHW_PUMP_HOURS: [None, UNIT_HOUR, "Hot Water Pump Hours"], + gw_vars.DATA_DHW_BURNER_HOURS: [ + None, UNIT_HOUR, "Hot Water Burner Hours"], + gw_vars.DATA_MASTER_OT_VERSION: [ + None, None, "Thermostat OpenTherm Version"], + gw_vars.DATA_SLAVE_OT_VERSION: [ + None, None, "Boiler OpenTherm Version"], + gw_vars.DATA_MASTER_PRODUCT_TYPE: [ + None, None, "Thermostat Product Type"], + gw_vars.DATA_MASTER_PRODUCT_VERSION: [ + None, None, "Thermostat Product Version"], + gw_vars.DATA_SLAVE_PRODUCT_TYPE: [None, None, "Boiler Product Type"], + gw_vars.DATA_SLAVE_PRODUCT_VERSION: [ + None, None, "Boiler Product Version"], + gw_vars.OTGW_MODE: [None, None, "Gateway/Monitor Mode"], + gw_vars.OTGW_DHW_OVRD: [None, None, "Gateway Hot Water Override Mode"], + gw_vars.OTGW_ABOUT: [None, None, "Gateway Firmware Version"], + gw_vars.OTGW_BUILD: [None, None, "Gateway Firmware Build"], + gw_vars.OTGW_CLOCKMHZ: [None, None, "Gateway Clock Speed"], + gw_vars.OTGW_LED_A: [None, None, "Gateway LED A Mode"], + gw_vars.OTGW_LED_B: [None, None, "Gateway LED B Mode"], + gw_vars.OTGW_LED_C: [None, None, "Gateway LED C Mode"], + gw_vars.OTGW_LED_D: [None, None, "Gateway LED D Mode"], + gw_vars.OTGW_LED_E: [None, None, "Gateway LED E Mode"], + gw_vars.OTGW_LED_F: [None, None, "Gateway LED F Mode"], + gw_vars.OTGW_GPIO_A: [None, None, "Gateway GPIO A Mode"], + gw_vars.OTGW_GPIO_B: [None, None, "Gateway GPIO B Mode"], + gw_vars.OTGW_SB_TEMP: [ + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + "Gateway Setback Temperature"], + gw_vars.OTGW_SETP_OVRD_MODE: [ + None, None, "Gateway Room Setpoint Override Mode"], + gw_vars.OTGW_SMART_PWR: [None, None, "Gateway Smart Power Mode"], + gw_vars.OTGW_THRM_DETECT: [None, None, "Gateway Thermostat Detection"], + gw_vars.OTGW_VREF: [None, None, "Gateway Reference Voltage Setting"], + } + sensors = [] + for var in discovery_info: + device_class = sensor_info[var][0] + unit = sensor_info[var][1] + friendly_name = sensor_info[var][2] + entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, var, hass=hass) + sensors.append( + OpenThermSensor(entity_id, var, device_class, unit, friendly_name)) + async_add_entities(sensors) + + +class OpenThermSensor(Entity): + """Representation of an OpenTherm Gateway sensor.""" + + def __init__(self, entity_id, var, device_class, unit, friendly_name): + """Initialize the sensor.""" + self.entity_id = entity_id + self._var = var + self._value = None + self._device_class = device_class + self._unit = unit + self._friendly_name = friendly_name + + async def async_added_to_hass(self): + """Subscribe to updates from the component.""" + _LOGGER.debug("Added OpenTherm Gateway sensor %s", self._friendly_name) + async_dispatcher_connect(self.hass, SIGNAL_OPENTHERM_GW_UPDATE, + self.receive_report) + + async def receive_report(self, status): + """Handle status updates from the component.""" + value = status.get(self._var) + if isinstance(value, float): + value = '{:2.1f}'.format(value) + self._value = value + self.async_schedule_update_ha_state() + + @property + def name(self): + """Return the friendly name of the sensor.""" + return self._friendly_name + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def state(self): + """Return the state of the device.""" + return self._value + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + @property + def should_poll(self): + """Return False because entity pushes its state.""" + return False diff --git a/homeassistant/components/sensor/openuv.py b/homeassistant/components/sensor/openuv.py index 22712aa306b..63527db42a6 100644 --- a/homeassistant/components/sensor/openuv.py +++ b/homeassistant/components/sensor/openuv.py @@ -64,7 +64,7 @@ class OpenUvSensor(OpenUvEntity): """Initialize the sensor.""" super().__init__(openuv) - self._dispatch_remove = None + self._async_unsub_dispatcher_connect = None self._entry_id = entry_id self._icon = icon self._latitude = openuv.client.latitude @@ -100,16 +100,20 @@ class OpenUvSensor(OpenUvEntity): """Return the unit the value is expressed in.""" return self._unit - @callback - def _update_data(self): - """Update the state.""" - self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): """Register callbacks.""" - self._dispatch_remove = async_dispatcher_connect( - self.hass, TOPIC_UPDATE, self._update_data) - self.async_on_remove(self._dispatch_remove) + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, TOPIC_UPDATE, update) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() async def async_update(self): """Update the state.""" diff --git a/homeassistant/components/sensor/rflink.py b/homeassistant/components/sensor/rflink.py index e01c441be84..f3ec776fda8 100644 --- a/homeassistant/components/sensor/rflink.py +++ b/homeassistant/components/sensor/rflink.py @@ -2,18 +2,23 @@ Support for Rflink sensors. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.rflink/ +https://home-assistant.io/components/sensor.rflink/ """ -from functools import partial import logging +import voluptuous as vol + from homeassistant.components.rflink import ( CONF_ALIASES, CONF_ALIASSES, CONF_AUTOMATIC_ADD, CONF_DEVICES, - DATA_DEVICE_REGISTER, DATA_ENTITY_LOOKUP, DOMAIN, EVENT_KEY_ID, - EVENT_KEY_SENSOR, EVENT_KEY_UNIT, RflinkDevice, cv, remove_deprecated, vol) + DATA_DEVICE_REGISTER, DATA_ENTITY_LOOKUP, EVENT_KEY_ID, + EVENT_KEY_SENSOR, EVENT_KEY_UNIT, RflinkDevice, remove_deprecated, + SIGNAL_AVAILABILITY, SIGNAL_HANDLE_EVENT, TMP_ENTITY) +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_PLATFORM, - CONF_UNIT_OF_MEASUREMENT) + ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_UNIT_OF_MEASUREMENT) +from homeassistant.helpers.dispatcher import (async_dispatcher_connect) DEPENDENCIES = ['rflink'] @@ -27,11 +32,10 @@ SENSOR_ICONS = { CONF_SENSOR_TYPE = 'sensor_type' -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): DOMAIN, +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_AUTOMATIC_ADD, default=True): cv.boolean, - vol.Optional(CONF_DEVICES, default={}): vol.Schema({ - cv.string: { + vol.Optional(CONF_DEVICES, default={}): { + cv.string: vol.Schema({ vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_SENSOR_TYPE): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, @@ -40,9 +44,9 @@ PLATFORM_SCHEMA = vol.Schema({ # deprecated config options vol.Optional(CONF_ALIASSES): vol.All(cv.ensure_list, [cv.string]), - }, - }), -}) + }) + }, +}, extra=vol.ALLOW_EXTRA) def lookup_unit_for_sensor_type(sensor_type): @@ -56,7 +60,7 @@ def lookup_unit_for_sensor_type(sensor_type): return UNITS.get(field_abbrev.get(sensor_type)) -def devices_from_config(domain_config, hass=None): +def devices_from_config(domain_config): """Parse configuration and add Rflink sensor devices.""" devices = [] for device_id, config in domain_config[CONF_DEVICES].items(): @@ -64,36 +68,26 @@ def devices_from_config(domain_config, hass=None): config[ATTR_UNIT_OF_MEASUREMENT] = lookup_unit_for_sensor_type( config[CONF_SENSOR_TYPE]) remove_deprecated(config) - device = RflinkSensor(device_id, hass, **config) + device = RflinkSensor(device_id, **config) devices.append(device) - # Register entity to listen to incoming rflink events - hass.data[DATA_ENTITY_LOOKUP][ - EVENT_KEY_SENSOR][device_id].append(device) return devices async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Rflink platform.""" - async_add_entities(devices_from_config(config, hass)) + async_add_entities(devices_from_config(config)) async def add_new_device(event): """Check if device is known, otherwise create device entity.""" device_id = event[EVENT_KEY_ID] - rflinksensor = partial(RflinkSensor, device_id, hass) - device = rflinksensor(event[EVENT_KEY_SENSOR], event[EVENT_KEY_UNIT]) + device = RflinkSensor(device_id, event[EVENT_KEY_SENSOR], + event[EVENT_KEY_UNIT], initial_event=event) # Add device entity async_add_entities([device]) - # Register entity to listen to incoming rflink events - hass.data[DATA_ENTITY_LOOKUP][ - EVENT_KEY_SENSOR][device_id].append(device) - - # Schedule task to process event after entity is created - hass.async_add_job(device.handle_event, event) - if config[CONF_AUTOMATIC_ADD]: hass.data[DATA_DEVICE_REGISTER][EVENT_KEY_SENSOR] = add_new_device @@ -101,17 +95,43 @@ async def async_setup_platform(hass, config, async_add_entities, class RflinkSensor(RflinkDevice): """Representation of a Rflink sensor.""" - def __init__(self, device_id, hass, sensor_type, unit_of_measurement, - **kwargs): + def __init__(self, device_id, sensor_type, unit_of_measurement, + initial_event=None, **kwargs): """Handle sensor specific args and super init.""" self._sensor_type = sensor_type self._unit_of_measurement = unit_of_measurement - super().__init__(device_id, hass, **kwargs) + super().__init__(device_id, initial_event=initial_event, **kwargs) def _handle_event(self, event): """Domain specific event handler.""" self._state = event['value'] + async def async_added_to_hass(self): + """Register update callback.""" + # Remove temporary bogus entity_id if added + tmp_entity = TMP_ENTITY.format(self._device_id) + if tmp_entity in self.hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_SENSOR][self._device_id]: + self.hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_SENSOR][self._device_id].remove(tmp_entity) + + # Register id and aliases + self.hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_SENSOR][self._device_id].append(self.entity_id) + if self._aliases: + for _id in self._aliases: + self.hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_SENSOR][_id].append(self.entity_id) + async_dispatcher_connect(self.hass, SIGNAL_AVAILABILITY, + self._availability_callback) + async_dispatcher_connect(self.hass, + SIGNAL_HANDLE_EVENT.format(self.entity_id), + self.handle_event_callback) + + # Process the initial event now that the entity is created + if self._initial_event: + self.handle_event_callback(self._initial_event) + @property def unit_of_measurement(self): """Return measurement unit.""" diff --git a/homeassistant/components/sensor/ring.py b/homeassistant/components/sensor/ring.py index 408971c60e1..92c033241e0 100644 --- a/homeassistant/components/sensor/ring.py +++ b/homeassistant/components/sensor/ring.py @@ -100,6 +100,7 @@ class RingSensor(Entity): self._data.name, SENSOR_TYPES.get(self._sensor_type)[0]) self._state = STATE_UNKNOWN self._tz = str(hass.config.time_zone) + self._unique_id = '{}-{}'.format(self._data.id, self._sensor_type) @property def name(self): @@ -111,6 +112,11 @@ class RingSensor(Entity): """Return the state of the sensor.""" return self._state + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + @property def device_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/sensor/rmvtransport.py b/homeassistant/components/sensor/rmvtransport.py index 0916765e12d..f9bd65c1a74 100644 --- a/homeassistant/components/sensor/rmvtransport.py +++ b/homeassistant/components/sensor/rmvtransport.py @@ -5,15 +5,18 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.rmvtransport/ """ import logging +from datetime import timedelta import voluptuous as vol import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import (CONF_NAME, ATTR_ATTRIBUTION) +from homeassistant.util import Throttle -REQUIREMENTS = ['PyRMVtransport==0.1'] +REQUIREMENTS = ['PyRMVtransport==0.1.3'] _LOGGER = logging.getLogger(__name__) @@ -21,11 +24,12 @@ CONF_NEXT_DEPARTURE = 'next_departure' CONF_STATION = 'station' CONF_DESTINATIONS = 'destinations' -CONF_DIRECTIONS = 'directions' +CONF_DIRECTION = 'direction' CONF_LINES = 'lines' CONF_PRODUCTS = 'products' CONF_TIME_OFFSET = 'time_offset' CONF_MAX_JOURNEYS = 'max_journeys' +CONF_TIMEOUT = 'timeout' DEFAULT_NAME = 'RMV Journey' @@ -46,51 +50,61 @@ ICONS = { } ATTRIBUTION = "Data provided by opendata.rmv.de" +SCAN_INTERVAL = timedelta(seconds=60) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_NEXT_DEPARTURE): [{ vol.Required(CONF_STATION): cv.string, vol.Optional(CONF_DESTINATIONS, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_DIRECTIONS, default=[]): - vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_DIRECTION): cv.string, vol.Optional(CONF_LINES, default=[]): vol.All(cv.ensure_list, [cv.positive_int, cv.string]), vol.Optional(CONF_PRODUCTS, default=VALID_PRODUCTS): vol.All(cv.ensure_list, [vol.In(VALID_PRODUCTS)]), vol.Optional(CONF_TIME_OFFSET, default=0): cv.positive_int, vol.Optional(CONF_MAX_JOURNEYS, default=5): cv.positive_int, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string}] + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string}], + vol.Optional(CONF_TIMEOUT, default=10): cv.positive_int }) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): """Set up the RMV departure sensor.""" + timeout = config.get(CONF_TIMEOUT) + + session = async_get_clientsession(hass) + sensors = [] for next_departure in config.get(CONF_NEXT_DEPARTURE): sensors.append( RMVDepartureSensor( + session, next_departure[CONF_STATION], next_departure.get(CONF_DESTINATIONS), - next_departure.get(CONF_DIRECTIONS), + next_departure.get(CONF_DIRECTION), next_departure.get(CONF_LINES), next_departure.get(CONF_PRODUCTS), next_departure.get(CONF_TIME_OFFSET), next_departure.get(CONF_MAX_JOURNEYS), - next_departure.get(CONF_NAME))) - add_entities(sensors, True) + next_departure.get(CONF_NAME), + timeout)) + async_add_entities(sensors, True) class RMVDepartureSensor(Entity): """Implementation of an RMV departure sensor.""" - def __init__(self, station, destinations, directions, - lines, products, time_offset, max_journeys, name): + def __init__(self, session, station, destinations, direction, lines, + products, time_offset, max_journeys, name, timeout): """Initialize the sensor.""" self._station = station self._name = name self._state = None - self.data = RMVDepartureData(station, destinations, directions, lines, - products, time_offset, max_journeys) + self.data = RMVDepartureData(session, station, destinations, + direction, lines, products, time_offset, + max_journeys, timeout) self._icon = ICONS[None] @property @@ -134,9 +148,10 @@ class RMVDepartureSensor(Entity): """Return the unit this state is expressed in.""" return "min" - def update(self): + async def async_update(self): """Get the latest data and update the state.""" - self.data.update() + await self.data.async_update() + if not self.data.departures: self._state = None self._icon = ICONS[None] @@ -151,27 +166,30 @@ class RMVDepartureSensor(Entity): class RMVDepartureData: """Pull data from the opendata.rmv.de web page.""" - def __init__(self, station_id, destinations, directions, - lines, products, time_offset, max_journeys): + def __init__(self, session, station_id, destinations, direction, lines, + products, time_offset, max_journeys, timeout): """Initialize the sensor.""" - import RMVtransport + from RMVtransport import RMVtransport + self.station = None self._station_id = station_id self._destinations = destinations - self._directions = directions + self._direction = direction self._lines = lines self._products = products self._time_offset = time_offset self._max_journeys = max_journeys - self.rmv = RMVtransport.RMVtransport() + self.rmv = RMVtransport(session, timeout) self.departures = [] - def update(self): + @Throttle(SCAN_INTERVAL) + async def async_update(self): """Update the connection data.""" try: - _data = self.rmv.get_departures(self._station_id, - products=self._products, - maxJourneys=50) + _data = await self.rmv.get_departures(self._station_id, + products=self._products, + directionId=self._direction, + maxJourneys=50) except ValueError: self.departures = [] _LOGGER.warning("Returned data not understood") diff --git a/homeassistant/components/sensor/rtorrent.py b/homeassistant/components/sensor/rtorrent.py new file mode 100644 index 00000000000..f71b9c6dbdb --- /dev/null +++ b/homeassistant/components/sensor/rtorrent.py @@ -0,0 +1,127 @@ +"""Support for monitoring the rtorrent BitTorrent client API.""" +import logging +import xmlrpc.client + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_URL, CONF_NAME, + CONF_MONITORED_VARIABLES, STATE_IDLE) +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.exceptions import PlatformNotReady + +_LOGGER = logging.getLogger(__name__) + +SENSOR_TYPE_CURRENT_STATUS = 'current_status' +SENSOR_TYPE_DOWNLOAD_SPEED = 'download_speed' +SENSOR_TYPE_UPLOAD_SPEED = 'upload_speed' + +DEFAULT_NAME = 'rtorrent' +SENSOR_TYPES = { + SENSOR_TYPE_CURRENT_STATUS: ['Status', None], + SENSOR_TYPE_DOWNLOAD_SPEED: ['Down Speed', 'kB/s'], + SENSOR_TYPE_UPLOAD_SPEED: ['Up Speed', 'kB/s'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_URL): cv.url, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MONITORED_VARIABLES, default=[]): vol.All( + cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the rtorrent sensors.""" + url = config[CONF_URL] + name = config[CONF_NAME] + + try: + rtorrent = xmlrpc.client.ServerProxy(url) + except (xmlrpc.client.ProtocolError, ConnectionRefusedError): + _LOGGER.error("Connection to rtorrent daemon failed") + raise PlatformNotReady + dev = [] + for variable in config[CONF_MONITORED_VARIABLES]: + dev.append(RTorrentSensor(variable, rtorrent, name)) + + add_entities(dev) + + +def format_speed(speed): + """Return a bytes/s measurement as a human readable string.""" + kb_spd = float(speed) / 1024 + return round(kb_spd, 2 if kb_spd < 0.1 else 1) + + +class RTorrentSensor(Entity): + """Representation of an rtorrent sensor.""" + + def __init__(self, sensor_type, rtorrent_client, client_name): + """Initialize the sensor.""" + self._name = SENSOR_TYPES[sensor_type][0] + self.client = rtorrent_client + self.type = sensor_type + self.client_name = client_name + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self.data = None + self._available = False + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {}'.format(self.client_name, self._name) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def available(self): + """Return true if device is available.""" + return self._available + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + def update(self): + """Get the latest data from rtorrent and updates the state.""" + multicall = xmlrpc.client.MultiCall(self.client) + multicall.throttle.global_up.rate() + multicall.throttle.global_down.rate() + + try: + self.data = multicall() + self._available = True + except (xmlrpc.client.ProtocolError, ConnectionRefusedError): + _LOGGER.error("Connection to rtorrent lost") + self._available = False + return + + upload = self.data[0] + download = self.data[1] + + if self.type == SENSOR_TYPE_CURRENT_STATUS: + if self.data: + if upload > 0 and download > 0: + self._state = 'Up/Down' + elif upload > 0 and download == 0: + self._state = 'Seeding' + elif upload == 0 and download > 0: + self._state = 'Downloading' + else: + self._state = STATE_IDLE + else: + self._state = None + + if self.data: + if self.type == SENSOR_TYPE_DOWNLOAD_SPEED: + self._state = format_speed(download) + elif self.type == SENSOR_TYPE_UPLOAD_SPEED: + self._state = format_speed(upload) diff --git a/homeassistant/components/sensor/shodan.py b/homeassistant/components/sensor/shodan.py index bd74bcaeb2c..1cce17cf64a 100644 --- a/homeassistant/components/sensor/shodan.py +++ b/homeassistant/components/sensor/shodan.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['shodan==1.10.2'] +REQUIREMENTS = ['shodan==1.10.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/speedtest.py b/homeassistant/components/sensor/speedtest.py index ee6cad61e20..a08eec56e17 100644 --- a/homeassistant/components/sensor/speedtest.py +++ b/homeassistant/components/sensor/speedtest.py @@ -32,7 +32,6 @@ CONF_ATTRIBUTION = "Data retrieved from Speedtest by Ookla" CONF_SECOND = 'second' CONF_MINUTE = 'minute' CONF_HOUR = 'hour' -CONF_DAY = 'day' CONF_SERVER_ID = 'server_id' CONF_MANUAL = 'manual' @@ -47,8 +46,6 @@ SENSOR_TYPES = { PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]), - vol.Optional(CONF_DAY): - vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(1, 31))]), vol.Optional(CONF_HOUR): vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 23))]), vol.Optional(CONF_MANUAL, default=False): cv.boolean, @@ -156,8 +153,7 @@ class SpeedtestData: if not config.get(CONF_MANUAL): track_time_change( hass, self.update, second=config.get(CONF_SECOND), - minute=config.get(CONF_MINUTE), hour=config.get(CONF_HOUR), - day=config.get(CONF_DAY)) + minute=config.get(CONF_MINUTE), hour=config.get(CONF_HOUR)) def update(self, now): """Get the latest data from speedtest.net.""" diff --git a/homeassistant/components/sensor/syncthru.py b/homeassistant/components/sensor/syncthru.py index 45a529012e3..862efb63fd7 100644 --- a/homeassistant/components/sensor/syncthru.py +++ b/homeassistant/components/sensor/syncthru.py @@ -115,6 +115,13 @@ class SyncThruSensor(Entity): self._name = name self._icon = 'mdi:printer' self._unit_of_measurement = None + self._id_suffix = '' + + @property + def unique_id(self): + """Return unique ID for the sensor.""" + serial = self.syncthru.serial_number() + return serial + self._id_suffix if serial else super().unique_id @property def name(self): @@ -145,6 +152,11 @@ class SyncThruSensor(Entity): class SyncThruMainSensor(SyncThruSensor): """Implementation of the main sensor, monitoring the general state.""" + def __init__(self, syncthru, name): + """Initialize the sensor.""" + super().__init__(syncthru, name) + self._id_suffix = '_main' + def update(self): """Get the latest data from SyncThru and update the state.""" self.syncthru.update() @@ -160,6 +172,7 @@ class SyncThruTonerSensor(SyncThruSensor): self._name = "{} Toner {}".format(name, color) self._color = color self._unit_of_measurement = '%' + self._id_suffix = '_toner_{}'.format(color) def update(self): """Get the latest data from SyncThru and update the state.""" @@ -180,6 +193,7 @@ class SyncThruDrumSensor(SyncThruSensor): self._name = "{} Drum {}".format(name, color) self._color = color self._unit_of_measurement = '%' + self._id_suffix = '_drum_{}'.format(color) def update(self): """Get the latest data from SyncThru and update the state.""" @@ -199,6 +213,7 @@ class SyncThruInputTraySensor(SyncThruSensor): super().__init__(syncthru, name) self._name = "{} Tray {}".format(name, number) self._number = number + self._id_suffix = '_tray_{}'.format(number) def update(self): """Get the latest data from SyncThru and update the state.""" @@ -220,6 +235,7 @@ class SyncThruOutputTraySensor(SyncThruSensor): super().__init__(syncthru, name) self._name = "{} Output Tray {}".format(name, number) self._number = number + self._id_suffix = '_output_tray_{}'.format(number) def update(self): """Get the latest data from SyncThru and update the state.""" diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index 77b3759d5fc..3fa45935617 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -57,22 +57,36 @@ async def async_setup_platform(hass, config, async_add_entities, entity_ids = set() manual_entity_ids = device_config.get(ATTR_ENTITY_ID) + invalid_templates = [] - for template in (state_template, icon_template, - entity_picture_template, friendly_name_template): + for tpl_name, template in ( + (CONF_VALUE_TEMPLATE, state_template), + (CONF_ICON_TEMPLATE, icon_template), + (CONF_ENTITY_PICTURE_TEMPLATE, entity_picture_template), + (CONF_FRIENDLY_NAME_TEMPLATE, friendly_name_template), + ): if template is None: continue template.hass = hass - if entity_ids == MATCH_ALL or manual_entity_ids is not None: + if manual_entity_ids is not None: continue template_entity_ids = template.extract_entities() if template_entity_ids == MATCH_ALL: entity_ids = MATCH_ALL - else: + # Cut off _template from name + invalid_templates.append(tpl_name[:-9]) + elif entity_ids != MATCH_ALL: entity_ids |= set(template_entity_ids) + if invalid_templates: + _LOGGER.warning( + 'Template sensor %s has no entity ids configured to track nor' + ' were we able to extract the entities to track from the %s ' + 'template(s). This entity will only be able to be updated ' + 'manually.', device, ', '.join(invalid_templates)) + if manual_entity_ids is not None: entity_ids = manual_entity_ids elif entity_ids != MATCH_ALL: @@ -131,8 +145,10 @@ class SensorTemplate(Entity): @callback def template_sensor_startup(event): """Update template on startup.""" - async_track_state_change( - self.hass, self._entities, template_sensor_state_listener) + if self._entities != MATCH_ALL: + # Track state change only for valid templates + async_track_state_change( + self.hass, self._entities, template_sensor_state_listener) self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/sensor/thermoworks_smoke.py b/homeassistant/components/sensor/thermoworks_smoke.py new file mode 100644 index 00000000000..e81a3974176 --- /dev/null +++ b/homeassistant/components/sensor/thermoworks_smoke.py @@ -0,0 +1,180 @@ +""" +Support for getting the state of a Thermoworks Smoke Thermometer. + +Requires Smoke Gateway Wifi with an internet connection. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.thermoworks_smoke/ +""" +import logging + +from requests import RequestException +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import TEMP_FAHRENHEIT, CONF_EMAIL, CONF_PASSWORD,\ + CONF_MONITORED_CONDITIONS, CONF_EXCLUDE, ATTR_BATTERY_LEVEL +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['thermoworks_smoke==0.1.7', 'stringcase==1.2.0'] + +_LOGGER = logging.getLogger(__name__) + +PROBE_1 = 'probe1' +PROBE_2 = 'probe2' +PROBE_1_MIN = 'probe1_min' +PROBE_1_MAX = 'probe1_max' +PROBE_2_MIN = 'probe2_min' +PROBE_2_MAX = 'probe2_max' +BATTERY_LEVEL = 'battery' +FIRMWARE = 'firmware' + +SERIAL_REGEX = '^(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$' + +# map types to labels +SENSOR_TYPES = { + PROBE_1: 'Probe 1', + PROBE_2: 'Probe 2', + PROBE_1_MIN: 'Probe 1 Min', + PROBE_1_MAX: 'Probe 1 Max', + PROBE_2_MIN: 'Probe 2 Min', + PROBE_2_MAX: 'Probe 2 Max', +} + +# exclude these keys from thermoworks data +EXCLUDE_KEYS = [ + FIRMWARE +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=[PROBE_1, PROBE_2]): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_EXCLUDE, default=[]): + vol.All(cv.ensure_list, [cv.matches_regex(SERIAL_REGEX)]), +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the thermoworks sensor.""" + import thermoworks_smoke + from requests.exceptions import HTTPError + + email = config[CONF_EMAIL] + password = config[CONF_PASSWORD] + monitored_variables = config[CONF_MONITORED_CONDITIONS] + excluded = config[CONF_EXCLUDE] + + try: + mgr = thermoworks_smoke.initialize_app(email, password, True, excluded) + + # list of sensor devices + dev = [] + + # get list of registered devices + for serial in mgr.serials(): + for variable in monitored_variables: + dev.append(ThermoworksSmokeSensor(variable, serial, mgr)) + + add_entities(dev, True) + except HTTPError as error: + msg = "{}".format(error.strerror) + if 'EMAIL_NOT_FOUND' in msg or \ + 'INVALID_PASSWORD' in msg: + _LOGGER.error("Invalid email and password combination") + else: + _LOGGER.error(msg) + + +class ThermoworksSmokeSensor(Entity): + """Implementation of a thermoworks smoke sensor.""" + + def __init__(self, sensor_type, serial, mgr): + """Initialize the sensor.""" + self._name = "{name} {sensor}".format( + name=mgr.name(serial), sensor=SENSOR_TYPES[sensor_type]) + self.type = sensor_type + self._state = None + self._attributes = {} + self._unit_of_measurement = TEMP_FAHRENHEIT + self._unique_id = "{serial}-{type}".format( + serial=serial, type=sensor_type) + self.serial = serial + self.mgr = mgr + self.update_unit() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unique_id(self): + """Return the unique id for the sensor.""" + return self._unique_id + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this sensor.""" + return self._unit_of_measurement + + def update_unit(self): + """Set the units from the data.""" + if PROBE_2 in self.type: + self._unit_of_measurement = self.mgr.units(self.serial, PROBE_2) + else: + self._unit_of_measurement = self.mgr.units(self.serial, PROBE_1) + + def update(self): + """Get the monitored data from firebase.""" + from stringcase import camelcase, snakecase + try: + values = self.mgr.data(self.serial) + + # set state from data based on type of sensor + self._state = values.get(camelcase(self.type)) + + # set units + self.update_unit() + + # set basic attributes for all sensors + self._attributes = { + 'time': values['time'], + 'localtime': values['localtime'] + } + + # set extended attributes for main probe sensors + if self.type in [PROBE_1, PROBE_2]: + for key, val in values.items(): + # add all attributes that don't contain any probe name + # or contain a matching probe name + if ( + (self.type == PROBE_1 and key.find(PROBE_2) == -1) + or + (self.type == PROBE_2 and key.find(PROBE_1) == -1) + ): + if key == BATTERY_LEVEL: + key = ATTR_BATTERY_LEVEL + else: + # strip probe label and convert to snake_case + key = snakecase(key.replace(self.type, '')) + # add to attrs + if key and key not in EXCLUDE_KEYS: + self._attributes[key] = val + # store actual unit because attributes are not converted + self._attributes['unit_of_min_max'] = self._unit_of_measurement + + except (RequestException, ValueError, KeyError): + _LOGGER.warning("Could not update status for %s", self.name) diff --git a/homeassistant/components/sensor/transport_nsw.py b/homeassistant/components/sensor/transport_nsw.py new file mode 100644 index 00000000000..08a2907748c --- /dev/null +++ b/homeassistant/components/sensor/transport_nsw.py @@ -0,0 +1,125 @@ +""" +Transport NSW (AU) sensor to query next leave event for a specified stop. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.transport_nsw/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_NAME, CONF_API_KEY, ATTR_ATTRIBUTION) + +REQUIREMENTS = ['PyTransportNSW==0.0.8'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_STOP_ID = 'stop_id' +ATTR_ROUTE = 'route' +ATTR_DUE_IN = 'due' +ATTR_DELAY = 'delay' +ATTR_REAL_TIME = 'real_time' + +CONF_ATTRIBUTION = "Data provided by Transport NSW" +CONF_STOP_ID = 'stop_id' +CONF_ROUTE = 'route' + +DEFAULT_NAME = "Next Bus" +ICON = "mdi:bus" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_STOP_ID): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_ROUTE, default=""): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Transport NSW sensor.""" + stop_id = config[CONF_STOP_ID] + api_key = config[CONF_API_KEY] + route = config.get(CONF_ROUTE) + name = config.get(CONF_NAME) + + data = PublicTransportData(stop_id, route, api_key) + add_entities([TransportNSWSensor(data, stop_id, name)], True) + + +class TransportNSWSensor(Entity): + """Implementation of an Transport NSW sensor.""" + + def __init__(self, data, stop_id, name): + """Initialize the sensor.""" + self.data = data + self._name = name + self._stop_id = stop_id + self._times = self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._times is not None: + return { + ATTR_DUE_IN: self._times[ATTR_DUE_IN], + ATTR_STOP_ID: self._stop_id, + ATTR_ROUTE: self._times[ATTR_ROUTE], + ATTR_DELAY: self._times[ATTR_DELAY], + ATTR_REAL_TIME: self._times[ATTR_REAL_TIME], + ATTR_ATTRIBUTION: CONF_ATTRIBUTION + } + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return 'min' + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + def update(self): + """Get the latest data from Transport NSW and update the states.""" + self.data.update() + self._times = self.data.info + self._state = self._times[ATTR_DUE_IN] + + +class PublicTransportData: + """The Class for handling the data retrieval.""" + + def __init__(self, stop_id, route, api_key): + """Initialize the data object.""" + import TransportNSW + self._stop_id = stop_id + self._route = route + self._api_key = api_key + self.info = {ATTR_ROUTE: self._route, + ATTR_DUE_IN: 'n/a', + ATTR_DELAY: 'n/a', + ATTR_REAL_TIME: 'n/a'} + self.tnsw = TransportNSW.TransportNSW() + + def update(self): + """Get the next leave time.""" + _data = self.tnsw.get_departures(self._stop_id, + self._route, + self._api_key) + self.info = {ATTR_ROUTE: _data['route'], + ATTR_DUE_IN: _data['due'], + ATTR_DELAY: _data['delay'], + ATTR_REAL_TIME: _data['real_time']} diff --git a/homeassistant/components/sensor/xiaomi_aqara.py b/homeassistant/components/sensor/xiaomi_aqara.py index 31366fe0097..33517e957b9 100644 --- a/homeassistant/components/sensor/xiaomi_aqara.py +++ b/homeassistant/components/sensor/xiaomi_aqara.py @@ -41,6 +41,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): elif device['model'] in ['gateway', 'gateway.v3', 'acpartner.v3']: devices.append(XiaomiSensor(device, 'Illumination', 'illumination', gateway)) + elif device['model'] in ['vibration']: + devices.append(XiaomiSensor(device, 'Bed Activity', + 'bed_activity', gateway)) + devices.append(XiaomiSensor(device, 'Tilt Angle', + 'final_tilt_angle', gateway)) + devices.append(XiaomiSensor(device, 'Coordination', + 'coordination', gateway)) + else: + _LOGGER.warning("Unmapped Device Model ") add_entities(devices) @@ -84,6 +93,9 @@ class XiaomiSensor(XiaomiDevice): value = data.get(self._data_key) if value is None: return False + if self._data_key in ['coordination', 'status']: + self._state = value + return True value = float(value) if self._data_key in ['temperature', 'humidity', 'pressure']: value /= 100 diff --git a/homeassistant/components/sensor/xiaomi_miio.py b/homeassistant/components/sensor/xiaomi_miio.py index 15af57bf46b..dd0785120ea 100644 --- a/homeassistant/components/sensor/xiaomi_miio.py +++ b/homeassistant/components/sensor/xiaomi_miio.py @@ -8,11 +8,13 @@ import logging import voluptuous as vol +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN) -from homeassistant.exceptions import PlatformNotReady + +REQUIREMENTS = ['python-miio==0.4.2', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) @@ -25,8 +27,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.4.1', 'construct==2.9.41'] - ATTR_POWER = 'power' ATTR_CHARGING = 'charging' ATTR_BATTERY_LEVEL = 'battery_level' diff --git a/homeassistant/components/sensor/zha.py b/homeassistant/components/sensor/zha.py index 0d5b40d1d98..9a9de0d6cf2 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -36,7 +36,9 @@ async def make_sensor(discovery_info): from zigpy.zcl.clusters.smartenergy import Metering from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement in_clusters = discovery_info['in_clusters'] - if RelativeHumidity.cluster_id in in_clusters: + if 'sub_component' in discovery_info: + sensor = discovery_info['sub_component'](**discovery_info) + elif RelativeHumidity.cluster_id in in_clusters: sensor = RelativeHumiditySensor(**discovery_info) elif TemperatureMeasurement.cluster_id in in_clusters: sensor = TemperatureSensor(**discovery_info) diff --git a/homeassistant/components/sensor/zoneminder.py b/homeassistant/components/sensor/zoneminder.py index d4164bbf721..8e4b57f1f38 100644 --- a/homeassistant/components/sensor/zoneminder.py +++ b/homeassistant/components/sensor/zoneminder.py @@ -54,6 +54,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for sensor in config[CONF_MONITORED_CONDITIONS]: sensors.append(ZMSensorEvents(monitor, include_archived, sensor)) + sensors.append(ZMSensorRunState(zm_client)) add_entities(sensors) @@ -114,3 +115,26 @@ class ZMSensorEvents(Entity): """Update the sensor.""" self._state = self._monitor.get_events( self.time_period, self._include_archived) + + +class ZMSensorRunState(Entity): + """Get the ZoneMinder run state.""" + + def __init__(self, client): + """Initialize run state sensor.""" + self._state = None + self._client = client + + @property + def name(self): + """Return the name of the sensor.""" + return 'Run State' + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def update(self): + """Update the sensor.""" + self._state = self._client.get_active_state() diff --git a/homeassistant/components/sensor/zwave.py b/homeassistant/components/sensor/zwave.py index c6356efe157..ce25b61146b 100644 --- a/homeassistant/components/sensor/zwave.py +++ b/homeassistant/components/sensor/zwave.py @@ -5,14 +5,31 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.zwave/ """ import logging +from homeassistant.core import callback from homeassistant.components.sensor import DOMAIN from homeassistant.components import zwave from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT -from homeassistant.components.zwave import async_setup_platform # noqa pylint: disable=unused-import +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old method of setting up Z-Wave sensors.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Z-Wave Sensor from Config Entry.""" + @callback + def async_add_sensor(sensor): + """Add Z-Wave Sensor.""" + async_add_entities([sensor]) + + async_dispatcher_connect(hass, 'zwave_new_sensor', async_add_sensor) + + def get_device(node, values, **kwargs): """Create Z-Wave entity device.""" # Generic Device mappings diff --git a/homeassistant/components/simplisafe/.translations/en.json b/homeassistant/components/simplisafe/.translations/en.json new file mode 100644 index 00000000000..b000335af8f --- /dev/null +++ b/homeassistant/components/simplisafe/.translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Account already registered", + "invalid_credentials": "Invalid credentials" + }, + "step": { + "user": { + "data": { + "code": "Code (for Home Assistant)", + "password": "Password", + "username": "Email Address" + }, + "title": "Fill in your information" + } + }, + "title": "SimpliSafe" + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/es.json b/homeassistant/components/simplisafe/.translations/es.json new file mode 100644 index 00000000000..12d0f63356f --- /dev/null +++ b/homeassistant/components/simplisafe/.translations/es.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Cuenta ya registrada", + "invalid_credentials": "Credenciales no v\u00e1lidas" + }, + "step": { + "user": { + "data": { + "code": "C\u00f3digo (para Home Assistant)", + "password": "Contrase\u00f1a", + "username": "Direcci\u00f3n de correo electr\u00f3nico" + }, + "title": "Rellene sus datos" + } + }, + "title": "SimpliSafe" + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/hu.json b/homeassistant/components/simplisafe/.translations/hu.json index ff2c2fc87b5..103bf4e18d0 100644 --- a/homeassistant/components/simplisafe/.translations/hu.json +++ b/homeassistant/components/simplisafe/.translations/hu.json @@ -1,10 +1,15 @@ { "config": { + "error": { + "invalid_credentials": "\u00c9rv\u00e9nytelen hiteles\u00edt\u0151 adatok" + }, "step": { "user": { "data": { - "password": "Jelsz\u00f3" - } + "password": "Jelsz\u00f3", + "username": "Email c\u00edm" + }, + "title": "T\u00f6ltsd ki az adataid" } } } diff --git a/homeassistant/components/simplisafe/.translations/nl.json b/homeassistant/components/simplisafe/.translations/nl.json new file mode 100644 index 00000000000..c84593c0b23 --- /dev/null +++ b/homeassistant/components/simplisafe/.translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Account bestaat al", + "invalid_credentials": "Ongeldige gebruikersgegevens" + }, + "step": { + "user": { + "data": { + "code": "Code (voor Home Assistant)", + "password": "Wachtwoord", + "username": "E-mailadres" + }, + "title": "Vul uw gegevens in" + } + }, + "title": "SimpliSafe" + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/pt.json b/homeassistant/components/simplisafe/.translations/pt.json new file mode 100644 index 00000000000..47929161976 --- /dev/null +++ b/homeassistant/components/simplisafe/.translations/pt.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Conta j\u00e1 registada", + "invalid_credentials": "Credenciais inv\u00e1lidas" + }, + "step": { + "user": { + "data": { + "code": "C\u00f3digo (para Home Assistant)", + "password": "Palavra-passe", + "username": "Endere\u00e7o de e-mail" + }, + "title": "Preencha as suas informa\u00e7\u00f5es" + } + }, + "title": "SimpliSafe" + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/ro.json b/homeassistant/components/simplisafe/.translations/ro.json index 7046b0992b1..b7e281a2bc2 100644 --- a/homeassistant/components/simplisafe/.translations/ro.json +++ b/homeassistant/components/simplisafe/.translations/ro.json @@ -7,11 +7,13 @@ "step": { "user": { "data": { + "code": "Cod (pentru Home Assistant)", "password": "Parola", "username": "Adresa de email" }, "title": "Completa\u021bi informa\u021biile dvs." } - } + }, + "title": "SimpliSafe" } } \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/tr.json b/homeassistant/components/simplisafe/.translations/tr.json new file mode 100644 index 00000000000..ec84b1b7c1c --- /dev/null +++ b/homeassistant/components/simplisafe/.translations/tr.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parola", + "username": "E-posta adresi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py new file mode 100644 index 00000000000..de6277c2ef1 --- /dev/null +++ b/homeassistant/components/simplisafe/__init__.py @@ -0,0 +1,142 @@ +""" +Support for SimpliSafe alarm systems. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/simplisafe/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_CODE, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_USERNAME) +from homeassistant.core import callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval + +from homeassistant.helpers import config_validation as cv + +from .config_flow import configured_instances +from .const import DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN, TOPIC_UPDATE + +REQUIREMENTS = ['simplisafe-python==3.1.12'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ACCOUNTS = 'accounts' + +DATA_LISTENER = 'listener' + +ACCOUNT_CONFIG_SCHEMA = vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_CODE): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + cv.time_period +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_ACCOUNTS): + vol.All(cv.ensure_list, [ACCOUNT_CONFIG_SCHEMA]), + }), +}, extra=vol.ALLOW_EXTRA) + + +@callback +def _async_save_refresh_token(hass, config_entry, token): + hass.config_entries.async_update_entry( + config_entry, data={ + **config_entry.data, CONF_TOKEN: token + }) + + +async def async_setup(hass, config): + """Set up the SimpliSafe component.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_CLIENT] = {} + hass.data[DOMAIN][DATA_LISTENER] = {} + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + for account in conf[CONF_ACCOUNTS]: + if account[CONF_USERNAME] in configured_instances(hass): + continue + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': SOURCE_IMPORT}, + data={ + CONF_USERNAME: account[CONF_USERNAME], + CONF_PASSWORD: account[CONF_PASSWORD], + CONF_CODE: account.get(CONF_CODE), + CONF_SCAN_INTERVAL: account[CONF_SCAN_INTERVAL], + })) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up SimpliSafe as config entry.""" + from simplipy import API + from simplipy.errors import InvalidCredentialsError, SimplipyError + + websession = aiohttp_client.async_get_clientsession(hass) + + try: + simplisafe = await API.login_via_token( + config_entry.data[CONF_TOKEN], websession) + except InvalidCredentialsError: + _LOGGER.error('Invalid credentials provided') + return False + except SimplipyError as err: + _LOGGER.error('Config entry failed: %s', err) + raise ConfigEntryNotReady + + _async_save_refresh_token(hass, config_entry, simplisafe.refresh_token) + + systems = await simplisafe.get_systems() + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = systems + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup( + config_entry, 'alarm_control_panel')) + + async def refresh(event_time): + """Refresh data from the SimpliSafe account.""" + for system in systems: + _LOGGER.debug('Updating system data: %s', system.system_id) + await system.update() + async_dispatcher_send(hass, TOPIC_UPDATE.format(system.system_id)) + + if system.api.refresh_token_dirty: + _async_save_refresh_token( + hass, config_entry, system.api.refresh_token) + + hass.data[DOMAIN][DATA_LISTENER][ + config_entry.entry_id] = async_track_time_interval( + hass, + refresh, + timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL])) + + return True + + +async def async_unload_entry(hass, entry): + """Unload a SimpliSafe config entry.""" + await hass.config_entries.async_forward_entry_unload( + entry, 'alarm_control_panel') + + hass.data[DOMAIN][DATA_CLIENT].pop(entry.entry_id) + remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop(entry.entry_id) + remove_listener() + + return True diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py new file mode 100644 index 00000000000..0a59dcb3e1d --- /dev/null +++ b/homeassistant/components/simplisafe/config_flow.py @@ -0,0 +1,80 @@ +"""Config flow to configure the SimpliSafe component.""" + +from collections import OrderedDict + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.const import ( + CONF_CODE, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_USERNAME) +from homeassistant.helpers import aiohttp_client + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + + +@callback +def configured_instances(hass): + """Return a set of configured SimpliSafe instances.""" + return set( + entry.data[CONF_USERNAME] + for entry in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class SimpliSafeFlowHandler(config_entries.ConfigFlow): + """Handle a SimpliSafe config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize the config flow.""" + self.data_schema = OrderedDict() + self.data_schema[vol.Required(CONF_USERNAME)] = str + self.data_schema[vol.Required(CONF_PASSWORD)] = str + self.data_schema[vol.Optional(CONF_CODE)] = str + + async def _show_form(self, errors=None): + """Show the form to the user.""" + return self.async_show_form( + step_id='user', + data_schema=vol.Schema(self.data_schema), + errors=errors if errors else {}, + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + from simplipy import API + from simplipy.errors import SimplipyError + + if not user_input: + return await self._show_form() + + if user_input[CONF_USERNAME] in configured_instances(self.hass): + return await self._show_form({CONF_USERNAME: 'identifier_exists'}) + + username = user_input[CONF_USERNAME] + websession = aiohttp_client.async_get_clientsession(self.hass) + + try: + simplisafe = await API.login_via_credentials( + username, user_input[CONF_PASSWORD], websession) + except SimplipyError: + return await self._show_form({'base': 'invalid_credentials'}) + + scan_interval = user_input.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data={ + CONF_USERNAME: username, + CONF_TOKEN: simplisafe.refresh_token, + CONF_SCAN_INTERVAL: scan_interval.seconds, + }, + ) diff --git a/homeassistant/components/simplisafe/const.py b/homeassistant/components/simplisafe/const.py new file mode 100644 index 00000000000..437197878e0 --- /dev/null +++ b/homeassistant/components/simplisafe/const.py @@ -0,0 +1,10 @@ +"""Define constants for the SimpliSafe component.""" +from datetime import timedelta + +DOMAIN = 'simplisafe' + +DATA_CLIENT = 'client' + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) + +TOPIC_UPDATE = 'update_{0}' diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json new file mode 100644 index 00000000000..5df0cf400d4 --- /dev/null +++ b/homeassistant/components/simplisafe/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "title": "SimpliSafe", + "step": { + "user": { + "title": "Fill in your information", + "data": { + "username": "Email Address", + "password": "Password", + "code": "Code (for Home Assistant)" + } + } + }, + "error": { + "identifier_exists": "Account already registered", + "invalid_credentials": "Invalid credentials" + } + } +} diff --git a/homeassistant/components/smhi/.translations/es.json b/homeassistant/components/smhi/.translations/es.json new file mode 100644 index 00000000000..627c534f6dd --- /dev/null +++ b/homeassistant/components/smhi/.translations/es.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "name_exists": "Nombre ya existe", + "wrong_location": "Ubicaci\u00f3n Suecia solamente" + }, + "step": { + "user": { + "data": { + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nombre" + }, + "title": "Ubicaci\u00f3n en Suecia" + } + }, + "title": "Servicio meteorol\u00f3gico sueco (SMHI)" + } +} \ No newline at end of file diff --git a/homeassistant/components/smhi/.translations/fr.json b/homeassistant/components/smhi/.translations/fr.json new file mode 100644 index 00000000000..d1378f183d5 --- /dev/null +++ b/homeassistant/components/smhi/.translations/fr.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "name_exists": "Ce nom est d\u00e9j\u00e0 utilis\u00e9" + }, + "step": { + "user": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Nom" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smhi/.translations/hu.json b/homeassistant/components/smhi/.translations/hu.json new file mode 100644 index 00000000000..740fc1a8179 --- /dev/null +++ b/homeassistant/components/smhi/.translations/hu.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "latitude": "Sz\u00e9less\u00e9g", + "longitude": "Hossz\u00fas\u00e1g" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smhi/.translations/pt.json b/homeassistant/components/smhi/.translations/pt.json new file mode 100644 index 00000000000..e814ffd5046 --- /dev/null +++ b/homeassistant/components/smhi/.translations/pt.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "name_exists": "Nome j\u00e1 existe", + "wrong_location": "Localiza\u00e7\u00e3o apenas na Su\u00e9cia" + }, + "step": { + "user": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Nome" + }, + "title": "Localiza\u00e7\u00e3o na Su\u00e9cia" + } + }, + "title": "Servi\u00e7o meteorol\u00f3gico sueco (SMHI)" + } +} \ No newline at end of file diff --git a/homeassistant/components/smhi/.translations/ro.json b/homeassistant/components/smhi/.translations/ro.json index 6fe28787655..6249e49d2d7 100644 --- a/homeassistant/components/smhi/.translations/ro.json +++ b/homeassistant/components/smhi/.translations/ro.json @@ -1,7 +1,8 @@ { "config": { "error": { - "name_exists": "Numele exist\u0103 deja" + "name_exists": "Numele exist\u0103 deja", + "wrong_location": "Loca\u021bia numai \u00een Suedia" }, "step": { "user": { diff --git a/homeassistant/components/smhi/.translations/tr.json b/homeassistant/components/smhi/.translations/tr.json new file mode 100644 index 00000000000..bb50f1e2a8d --- /dev/null +++ b/homeassistant/components/smhi/.translations/tr.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "name_exists": "Bu ad zaten var", + "wrong_location": "Konum sadece \u0130sve\u00e7" + }, + "step": { + "user": { + "data": { + "latitude": "Enlem", + "longitude": "Boylam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py new file mode 100644 index 00000000000..2421addfd0c --- /dev/null +++ b/homeassistant/components/smhi/__init__.py @@ -0,0 +1,39 @@ +""" +Component for the swedish weather institute weather service. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/smhi/ +""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Config, HomeAssistant + +# Have to import for config_flow to work +# even if they are not used here +from .config_flow import smhi_locations # noqa: F401 +from .const import DOMAIN # noqa: F401 + +REQUIREMENTS = ['smhi-pkg==1.0.5'] + +DEFAULT_NAME = 'smhi' + + +async def async_setup(hass: HomeAssistant, config: Config) -> bool: + """Set up configured smhi.""" + # We allow setup only through config flow type of config + return True + + +async def async_setup_entry(hass: HomeAssistant, + config_entry: ConfigEntry) -> bool: + """Set up smhi forecast as config entry.""" + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + config_entry, 'weather')) + return True + + +async def async_unload_entry(hass: HomeAssistant, + config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + await hass.config_entries.async_forward_entry_unload( + config_entry, 'weather') + return True diff --git a/homeassistant/components/smhi/config_flow.py b/homeassistant/components/smhi/config_flow.py new file mode 100644 index 00000000000..e461c6d195d --- /dev/null +++ b/homeassistant/components/smhi/config_flow.py @@ -0,0 +1,122 @@ +"""Config flow to configure smhi component. + +First time the user creates the configuration and +a valid location is set in the hass configuration yaml +it will use that location and use it as default values. + +Additional locations can be added in config form. +The input location will be checked by invoking +the API. Exception will be thrown if the location +is not supported by the API (Swedish locations only) +""" +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant import config_entries, data_entry_flow +from homeassistant.const import (CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import aiohttp_client +from homeassistant.util import slugify + +from .const import DOMAIN, HOME_LOCATION_NAME + + +@callback +def smhi_locations(hass: HomeAssistant): + """Return configurations of SMHI component.""" + return set((slugify(entry.data[CONF_NAME])) for + entry in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class SmhiFlowHandler(data_entry_flow.FlowHandler): + """Config flow for SMHI component.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self) -> None: + """Initialize SMHI forecast configuration flow.""" + self._errors = {} + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + self._errors = {} + + if user_input is not None: + is_ok = await self._check_location( + user_input[CONF_LONGITUDE], + user_input[CONF_LATITUDE] + ) + if is_ok: + name = slugify(user_input[CONF_NAME]) + if not self._name_in_configuration_exists(name): + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input, + ) + + self._errors[CONF_NAME] = 'name_exists' + else: + self._errors['base'] = 'wrong_location' + + # If hass config has the location set and + # is a valid coordinate the default location + # is set as default values in the form + if not smhi_locations(self.hass): + if await self._homeassistant_location_exists(): + return await self._show_config_form( + name=HOME_LOCATION_NAME, + latitude=self.hass.config.latitude, + longitude=self.hass.config.longitude + ) + + return await self._show_config_form() + + async def _homeassistant_location_exists(self) -> bool: + """Return true if default location is set and is valid.""" + if self.hass.config.latitude != 0.0 and \ + self.hass.config.longitude != 0.0: + # Return true if valid location + if await self._check_location( + self.hass.config.longitude, + self.hass.config.latitude): + return True + return False + + def _name_in_configuration_exists(self, name: str) -> bool: + """Return True if name exists in configuration.""" + if name in smhi_locations(self.hass): + return True + return False + + async def _show_config_form(self, + name: str = None, + latitude: str = None, + longitude: str = None): + """Show the configuration form to edit location data.""" + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required(CONF_NAME, default=name): str, + vol.Required(CONF_LATITUDE, default=latitude): cv.latitude, + vol.Required(CONF_LONGITUDE, default=longitude): cv.longitude + }), + errors=self._errors, + ) + + async def _check_location(self, longitude: str, latitude: str) -> bool: + """Return true if location is ok.""" + from smhi.smhi_lib import Smhi, SmhiForecastException + try: + session = aiohttp_client.async_get_clientsession(self.hass) + smhi_api = Smhi(longitude, latitude, session=session) + + await smhi_api.async_get_forecast() + + return True + except SmhiForecastException: + # The API will throw an exception if faulty location + pass + + return False diff --git a/homeassistant/components/smhi/const.py b/homeassistant/components/smhi/const.py new file mode 100644 index 00000000000..49e0f295873 --- /dev/null +++ b/homeassistant/components/smhi/const.py @@ -0,0 +1,12 @@ +"""Constants in smhi component.""" +import logging +from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN + +HOME_LOCATION_NAME = 'Home' + +ATTR_SMHI_CLOUDINESS = 'cloudiness' +DOMAIN = 'smhi' +LOGGER = logging.getLogger('homeassistant.components.smhi') +ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".smhi_{}" +ENTITY_ID_SENSOR_FORMAT_HOME = ENTITY_ID_SENSOR_FORMAT.format( + HOME_LOCATION_NAME) diff --git a/homeassistant/components/smhi/strings.json b/homeassistant/components/smhi/strings.json new file mode 100644 index 00000000000..dbf1172b7d6 --- /dev/null +++ b/homeassistant/components/smhi/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "title": "Swedish weather service (SMHI)", + "step": { + "user": { + "title": "Location in Sweden", + "data": { + "name": "Name", + "latitude": "Latitude", + "longitude": "Longitude" + } + } + }, + "error": { + "name_exists": "Name already exists", + "wrong_location": "Location Sweden only" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/ro.json b/homeassistant/components/sonos/.translations/ro.json new file mode 100644 index 00000000000..e442ab9504e --- /dev/null +++ b/homeassistant/components/sonos/.translations/ro.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nu exist\u0103 dispozitive Sonos g\u0103site \u00een re\u021bea.", + "single_instance_allowed": "Este necesar\u0103 o singur\u0103 configurare a Sonos." + }, + "step": { + "confirm": { + "description": "Dori\u021bi s\u0103 configura\u021bi Sonos?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index 0562292acec..685402611a0 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -260,6 +260,7 @@ class BroadlinkSP1Switch(BroadlinkRMSwitch): super().__init__(friendly_name, friendly_name, device, None, None) self._command_on = 1 self._command_off = 0 + self._load_power = None def _sendpacket(self, packet, retry=2): """Send packet to device.""" @@ -288,6 +289,14 @@ class BroadlinkSP2Switch(BroadlinkSP1Switch): """Return the polling state.""" return True + @property + def current_power_w(self): + """Return the current power usage in Watt.""" + try: + return round(self._load_power, 2) + except (ValueError, TypeError): + return None + def update(self): """Synchronize state with switch.""" self._update() @@ -296,6 +305,7 @@ class BroadlinkSP2Switch(BroadlinkSP1Switch): """Update the state of the device.""" try: state = self._device.check_power() + load_power = self._device.get_energy() except (socket.timeout, ValueError) as error: if retry < 1: _LOGGER.error(error) @@ -306,6 +316,7 @@ class BroadlinkSP2Switch(BroadlinkSP1Switch): if state is None and retry > 0: return self._update(retry-1) self._state = state + self._load_power = load_power class BroadlinkMP1Slot(BroadlinkRMSwitch): diff --git a/homeassistant/components/switch/elkm1.py b/homeassistant/components/switch/elkm1.py new file mode 100644 index 00000000000..a838e1b948e --- /dev/null +++ b/homeassistant/components/switch/elkm1.py @@ -0,0 +1,40 @@ +""" +Support for control of ElkM1 outputs (relays). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.elkm1/ +""" + + +from homeassistant.components.elkm1 import ( + DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities) +from homeassistant.components.switch import SwitchDevice + +DEPENDENCIES = [ELK_DOMAIN] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Create the Elk-M1 switch platform.""" + if discovery_info is None: + return + elk = hass.data[ELK_DOMAIN]['elk'] + entities = create_elk_entities(hass, elk.outputs, 'output', ElkOutput, []) + async_add_entities(entities, True) + + +class ElkOutput(ElkEntity, SwitchDevice): + """Elk output as switch.""" + + @property + def is_on(self) -> bool: + """Get the current output status.""" + return self._element.output_on + + async def async_turn_on(self, **kwargs): + """Turn on the output.""" + self._element.turn_on(0) + + async def async_turn_off(self, **kwargs): + """Turn off the output.""" + self._element.turn_off() diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index 05e0497155a..00388822be1 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -19,7 +19,7 @@ from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.const import ( ATTR_ENTITY_ID, CONF_NAME, CONF_PLATFORM, CONF_LIGHTS, CONF_MODE, SERVICE_TURN_ON) -from homeassistant.helpers.event import track_time_change +from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.sun import get_astral_event_date from homeassistant.util import slugify from homeassistant.util.color import ( @@ -180,8 +180,10 @@ class FluxSwitch(SwitchDevice): # Make initial update self.flux_update() - self.unsub_tracker = track_time_change( - self.hass, self.flux_update, second=[0, self._interval]) + self.unsub_tracker = track_time_interval( + self.hass, + self.flux_update, + datetime.timedelta(seconds=self._interval)) self.schedule_update_ha_state() diff --git a/homeassistant/components/switch/homekit_controller.py b/homeassistant/components/switch/homekit_controller.py index a3db6060fcf..374e59aa77b 100644 --- a/homeassistant/components/switch/homekit_controller.py +++ b/homeassistant/components/switch/homekit_controller.py @@ -32,8 +32,7 @@ class HomeKitSwitch(HomeKitEntity, SwitchDevice): def update_characteristics(self, characteristics): """Synchronise the switch state with Home Assistant.""" - # pylint: disable=import-error - import homekit + import homekit # pylint: disable=import-error for characteristic in characteristics: ctype = characteristic['type'] diff --git a/homeassistant/components/switch/knx.py b/homeassistant/components/switch/knx.py index 678a8d4775f..26b9f77028d 100644 --- a/homeassistant/components/switch/knx.py +++ b/homeassistant/components/switch/knx.py @@ -41,7 +41,7 @@ def async_add_entities_discovery(hass, discovery_info, async_add_entities): entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] - entities.append(KNXSwitch(hass, device)) + entities.append(KNXSwitch(device)) async_add_entities(entities) @@ -55,17 +55,15 @@ def async_add_entities_config(hass, config, async_add_entities): group_address=config.get(CONF_ADDRESS), group_address_state=config.get(CONF_STATE_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(switch) - async_add_entities([KNXSwitch(hass, switch)]) + async_add_entities([KNXSwitch(switch)]) class KNXSwitch(SwitchDevice): """Representation of a KNX switch.""" - def __init__(self, hass, device): + def __init__(self, device): """Initialize of KNX switch.""" self.device = device - self.hass = hass - self.async_register_callbacks() @callback def async_register_callbacks(self): @@ -75,6 +73,10 @@ class KNXSwitch(SwitchDevice): await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) + async def async_added_to_hass(self): + """Store register state change callback.""" + self.async_register_callbacks() + @property def name(self): """Return the name of the KNX device.""" diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index bb57f179340..ad2b963629e 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -14,12 +14,12 @@ from homeassistant.components.mqtt import ( ATTR_DISCOVERY_HASH, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_AVAILABILITY_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, MqttAvailability, - MqttDiscoveryUpdate) + MqttDiscoveryUpdate, MqttEntityDeviceInfo) from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW from homeassistant.components.switch import SwitchDevice from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_OFF, - CONF_PAYLOAD_ON, CONF_ICON, STATE_ON) + CONF_PAYLOAD_ON, CONF_ICON, STATE_ON, CONF_DEVICE) from homeassistant.components import mqtt, switch import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -47,6 +47,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_STATE_OFF): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @@ -94,13 +95,15 @@ async def _async_setup_entity(hass, config, async_add_entities, config.get(CONF_PAYLOAD_NOT_AVAILABLE), config.get(CONF_UNIQUE_ID), value_template, + config.get(CONF_DEVICE), discovery_hash, ) async_add_entities([newswitch]) -class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, SwitchDevice): +class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, + SwitchDevice): """Representation of a switch that can be toggled using MQTT.""" def __init__(self, name, icon, @@ -108,11 +111,13 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, SwitchDevice): qos, retain, payload_on, payload_off, state_on, state_off, optimistic, payload_available, payload_not_available, unique_id: Optional[str], - value_template, discovery_hash): + value_template, device_config: Optional[ConfigType], + discovery_hash): """Initialize the MQTT switch.""" MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) MqttDiscoveryUpdate.__init__(self, discovery_hash) + MqttEntityDeviceInfo.__init__(self, device_config) self._state = False self._name = name self._icon = icon diff --git a/homeassistant/components/switch/neato.py b/homeassistant/components/switch/neato.py index d9850c1589a..0b49cb71ba2 100644 --- a/homeassistant/components/switch/neato.py +++ b/homeassistant/components/switch/neato.py @@ -52,6 +52,7 @@ class NeatoConnectedSwitch(ToggleEntity): self._state = None self._schedule_state = None self._clean_state = None + self._robot_serial = self.robot.serial def update(self): """Update the states of Neato switches.""" @@ -83,6 +84,11 @@ class NeatoConnectedSwitch(ToggleEntity): """Return True if entity is available.""" return self._state + @property + def unique_id(self): + """Return a unique ID.""" + return self._robot_serial + @property def is_on(self): """Return true if switch is on.""" diff --git a/homeassistant/components/switch/netio.py b/homeassistant/components/switch/netio.py index fc6086f9897..4492697406d 100644 --- a/homeassistant/components/switch/netio.py +++ b/homeassistant/components/switch/netio.py @@ -19,7 +19,7 @@ from homeassistant.const import ( from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pynetio==0.1.6'] +REQUIREMENTS = ['pynetio==0.1.9.1'] _LOGGER = logging.getLogger(__name__) @@ -151,15 +151,15 @@ class NetioSwitch(SwitchDevice): def _set(self, value): val = list('uuuu') - val[self.outlet - 1] = '1' if value else '0' + val[int(self.outlet) - 1] = '1' if value else '0' self.netio.get('port list %s' % ''.join(val)) - self.netio.states[self.outlet - 1] = value + self.netio.states[int(self.outlet) - 1] = value self.schedule_update_ha_state() @property def is_on(self): """Return the switch's status.""" - return self.netio.states[self.outlet - 1] + return self.netio.states[int(self.outlet) - 1] def update(self): """Update the state.""" @@ -176,14 +176,14 @@ class NetioSwitch(SwitchDevice): @property def current_power_w(self): """Return actual power.""" - return self.netio.consumptions[self.outlet - 1] + return self.netio.consumptions[int(self.outlet) - 1] @property def cumulated_consumption_kwh(self): """Return the total enerygy consumption since start_date.""" - return self.netio.cumulated_consumptions[self.outlet - 1] + return self.netio.cumulated_consumptions[int(self.outlet) - 1] @property def start_date(self): """Point in time when the energy accumulation started.""" - return self.netio.start_dates[self.outlet - 1] + return self.netio.start_dates[int(self.outlet) - 1] diff --git a/homeassistant/components/switch/recswitch.py b/homeassistant/components/switch/recswitch.py new file mode 100644 index 00000000000..636c302cea1 --- /dev/null +++ b/homeassistant/components/switch/recswitch.py @@ -0,0 +1,101 @@ +""" +Support for Ankuoo RecSwitch MS6126 devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.recswitch/ +""" + +import logging + +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +import homeassistant.helpers.config_validation as cv + + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['pyrecswitch==1.0.2'] + +DEFAULT_NAME = 'RecSwitch {0}' + +DATA_RSN = 'RSN' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_MAC): vol.All(cv.string, vol.Upper), + vol.Optional(CONF_NAME): cv.string, +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the device.""" + from pyrecswitch import RSNetwork + + host = config[CONF_HOST] + mac_address = config[CONF_MAC] + device_name = config.get(CONF_NAME) + + if not hass.data.get(DATA_RSN): + hass.data[DATA_RSN] = RSNetwork() + job = hass.data[DATA_RSN].create_datagram_endpoint(loop=hass.loop) + hass.async_create_task(job) + + device = hass.data[DATA_RSN].register_device(mac_address, host) + async_add_entities([RecSwitchSwitch(device, device_name, mac_address)]) + + +class RecSwitchSwitch(SwitchDevice): + """Representation of a recswitch device.""" + + def __init__(self, device, device_name, mac_address): + """Initialize a recswitch device.""" + self.gpio_state = False + self.device = device + self.device_name = device_name + self.mac_address = mac_address + if not self.device_name: + self.device_name = DEFAULT_NAME.format(self.mac_address) + + @property + def unique_id(self): + """Return the switch unique ID.""" + return self.mac_address + + @property + def name(self): + """Return the switch name.""" + return self.device_name + + @property + def is_on(self): + """Return true if switch is on.""" + return self.gpio_state + + async def async_turn_on(self, **kwargs): + """Turn on the switch.""" + await self.async_set_gpio_status(True) + + async def async_turn_off(self, **kwargs): + """Turn off the switch.""" + await self.async_set_gpio_status(False) + + async def async_set_gpio_status(self, status): + """Set the switch status.""" + from pyrecswitch import RSNetworkError + try: + ret = await self.device.set_gpio_status(status) + self.gpio_state = ret.state + except RSNetworkError as error: + _LOGGER.error('Setting status to %s: %r', self.name, error) + + async def async_update(self): + """Update the current switch status.""" + from pyrecswitch import RSNetworkError + try: + ret = await self.device.get_gpio_status() + self.gpio_state = ret.state + except RSNetworkError as error: + _LOGGER.error('Reading status from %s: %r', self.name, error) diff --git a/homeassistant/components/switch/rflink.py b/homeassistant/components/switch/rflink.py index 2bbe3e3f03d..51bf5543584 100644 --- a/homeassistant/components/switch/rflink.py +++ b/homeassistant/components/switch/rflink.py @@ -6,27 +6,27 @@ https://home-assistant.io/components/switch.rflink/ """ import logging +import voluptuous as vol + from homeassistant.components.rflink import ( CONF_ALIASES, CONF_ALIASSES, CONF_DEVICE_DEFAULTS, CONF_DEVICES, CONF_FIRE_EVENT, CONF_GROUP, CONF_GROUP_ALIASES, CONF_GROUP_ALIASSES, CONF_NOGROUP_ALIASES, CONF_NOGROUP_ALIASSES, CONF_SIGNAL_REPETITIONS, - DATA_ENTITY_GROUP_LOOKUP, DATA_ENTITY_LOOKUP, DEVICE_DEFAULTS_SCHEMA, - DOMAIN, EVENT_KEY_COMMAND, SwitchableRflinkDevice, cv, remove_deprecated, - vol) -from homeassistant.components.switch import SwitchDevice -from homeassistant.const import CONF_NAME, CONF_PLATFORM -from homeassistant.helpers.deprecation import get_deprecated + DEVICE_DEFAULTS_SCHEMA, SwitchableRflinkDevice, remove_deprecated) +from homeassistant.components.switch import ( + PLATFORM_SCHEMA, SwitchDevice) +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_NAME DEPENDENCIES = ['rflink'] _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): DOMAIN, +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({})): - DEVICE_DEFAULTS_SCHEMA, - vol.Optional(CONF_DEVICES, default={}): vol.Schema({ - cv.string: { + DEVICE_DEFAULTS_SCHEMA, + vol.Optional(CONF_DEVICES, default={}): { + cv.string: vol.Schema({ vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_ALIASES, default=[]): vol.All(cv.ensure_list, [cv.string]), @@ -44,50 +44,27 @@ PLATFORM_SCHEMA = vol.Schema({ vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_NOGROUP_ALIASSES): vol.All(cv.ensure_list, [cv.string]), - }, - }), -}) + }) + }, +}, extra=vol.ALLOW_EXTRA) -def devices_from_config(domain_config, hass=None): +def devices_from_config(domain_config): """Parse configuration and add Rflink switch devices.""" devices = [] for device_id, config in domain_config[CONF_DEVICES].items(): device_config = dict(domain_config[CONF_DEVICE_DEFAULTS], **config) remove_deprecated(device_config) - device = RflinkSwitch(device_id, hass, **device_config) + device = RflinkSwitch(device_id, **device_config) devices.append(device) - # Register entity (and aliases) to listen to incoming rflink events - # Device id and normal aliases respond to normal and group command - hass.data[DATA_ENTITY_LOOKUP][ - EVENT_KEY_COMMAND][device_id].append(device) - if config[CONF_GROUP]: - hass.data[DATA_ENTITY_GROUP_LOOKUP][ - EVENT_KEY_COMMAND][device_id].append(device) - for _id in get_deprecated(config, CONF_ALIASES, CONF_ALIASSES): - hass.data[DATA_ENTITY_LOOKUP][ - EVENT_KEY_COMMAND][_id].append(device) - hass.data[DATA_ENTITY_GROUP_LOOKUP][ - EVENT_KEY_COMMAND][_id].append(device) - # group_aliases only respond to group commands - for _id in get_deprecated( - config, CONF_GROUP_ALIASES, CONF_GROUP_ALIASSES): - hass.data[DATA_ENTITY_GROUP_LOOKUP][ - EVENT_KEY_COMMAND][_id].append(device) - # nogroup_aliases only respond to normal commands - for _id in get_deprecated( - config, CONF_NOGROUP_ALIASES, CONF_NOGROUP_ALIASSES): - hass.data[DATA_ENTITY_LOOKUP][ - EVENT_KEY_COMMAND][_id].append(device) - return devices async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Rflink platform.""" - async_add_entities(devices_from_config(config, hass)) + async_add_entities(devices_from_config(config)) class RflinkSwitch(SwitchableRflinkDevice, SwitchDevice): diff --git a/homeassistant/components/switch/switchmate.py b/homeassistant/components/switch/switchmate.py index 4955d72c5e3..7f00964cd20 100644 --- a/homeassistant/components/switch/switchmate.py +++ b/homeassistant/components/switch/switchmate.py @@ -13,7 +13,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA from homeassistant.const import CONF_NAME, CONF_MAC -REQUIREMENTS = ['pySwitchmate==0.4.1'] +REQUIREMENTS = ['pySwitchmate==0.4.2'] _LOGGER = logging.getLogger(__name__) @@ -52,6 +52,11 @@ class Switchmate(SwitchDevice): """Return a unique, HASS-friendly identifier for this entity.""" return self._mac.replace(':', '') + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._device.available + @property def name(self) -> str: """Return the name of the switch.""" diff --git a/homeassistant/components/switch/template.py b/homeassistant/components/switch/template.py index 724fcbf6075..51cea68f6b3 100644 --- a/homeassistant/components/switch/template.py +++ b/homeassistant/components/switch/template.py @@ -151,11 +151,11 @@ class SwitchTemplate(SwitchDevice): async def async_turn_on(self, **kwargs): """Fire the on action.""" - await self._on_script.async_run() + await self._on_script.async_run(context=self._context) async def async_turn_off(self, **kwargs): """Fire the off action.""" - await self._off_script.async_run() + await self._off_script.async_run(context=self._context) async def async_update(self): """Update the state from the template.""" diff --git a/homeassistant/components/switch/unifi.py b/homeassistant/components/switch/unifi.py new file mode 100644 index 00000000000..dc02068c4a8 --- /dev/null +++ b/homeassistant/components/switch/unifi.py @@ -0,0 +1,230 @@ +""" +Support for devices connected to UniFi POE. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.unifi/ +""" + +import asyncio +import logging + +from datetime import timedelta + +import async_timeout + +from homeassistant.components import unifi +from homeassistant.components.switch import SwitchDevice +from homeassistant.components.unifi.const import ( + CONF_CONTROLLER, CONF_SITE_ID, CONTROLLER_ID, DOMAIN) +from homeassistant.const import CONF_HOST +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC + +DEPENDENCIES = [DOMAIN] +SCAN_INTERVAL = timedelta(seconds=15) + +LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Component doesn't support configuration through configuration.yaml.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up switches for UniFi component. + + Switches are controlling network switch ports with Poe. + """ + controller_id = CONTROLLER_ID.format( + host=config_entry.data[CONF_CONTROLLER][CONF_HOST], + site=config_entry.data[CONF_CONTROLLER][CONF_SITE_ID] + ) + controller = hass.data[unifi.DOMAIN][controller_id] + switches = {} + + progress = None + update_progress = set() + + async def request_update(object_id): + """Request an update.""" + nonlocal progress + update_progress.add(object_id) + + if progress is not None: + return await progress + + progress = asyncio.ensure_future(update_controller()) + result = await progress + progress = None + update_progress.clear() + return result + + async def update_controller(): + """Update the values of the controller.""" + tasks = [async_update_items( + controller, async_add_entities, request_update, + switches, update_progress + )] + await asyncio.wait(tasks) + + await update_controller() + + +async def async_update_items(controller, async_add_entities, + request_controller_update, switches, + progress_waiting): + """Update POE port state from the controller.""" + import aiounifi + + @callback + def update_switch_state(): + """Tell switches to reload state.""" + for client_id, client in switches.items(): + if client_id not in progress_waiting: + client.async_schedule_update_ha_state() + + try: + with async_timeout.timeout(4): + await controller.api.clients.update() + await controller.api.devices.update() + + except aiounifi.LoginRequired: + try: + with async_timeout.timeout(5): + await controller.api.login() + except (asyncio.TimeoutError, aiounifi.AiounifiException): + if controller.available: + controller.available = False + update_switch_state() + return + + except (asyncio.TimeoutError, aiounifi.AiounifiException): + if controller.available: + LOGGER.error('Unable to reach controller %s', controller.host) + controller.available = False + update_switch_state() + return + + if not controller.available: + LOGGER.info('Reconnected to controller %s', controller.host) + controller.available = True + + new_switches = [] + devices = controller.api.devices + for client_id in controller.api.clients: + + if client_id in progress_waiting: + continue + + if client_id in switches: + LOGGER.debug("Updating UniFi switch %s (%s)", + switches[client_id].entity_id, + switches[client_id].client.mac) + switches[client_id].async_schedule_update_ha_state() + continue + + client = controller.api.clients[client_id] + # Network device with active POE + if not client.is_wired or client.sw_mac not in devices or \ + not devices[client.sw_mac].ports[client.sw_port].port_poe or \ + not devices[client.sw_mac].ports[client.sw_port].poe_enable: + continue + + # Multiple POE-devices on same port means non UniFi POE driven switch + multi_clients_on_port = False + for client2 in controller.api.clients.values(): + if client.mac != client2.mac and \ + client.sw_mac == client2.sw_mac and \ + client.sw_port == client2.sw_port: + multi_clients_on_port = True + break + + if multi_clients_on_port: + continue + + switches[client_id] = UniFiSwitch( + client, controller, request_controller_update) + new_switches.append(switches[client_id]) + LOGGER.debug("New UniFi switch %s (%s)", client.hostname, client.mac) + + if new_switches: + async_add_entities(new_switches) + + +class UniFiSwitch(SwitchDevice): + """Representation of a client that uses POE.""" + + def __init__(self, client, controller, request_controller_update): + """Set up switch.""" + self.client = client + self.controller = controller + self.poe_mode = None + if self.port.poe_mode != 'off': + self.poe_mode = self.port.poe_mode + self.async_request_controller_update = request_controller_update + + async def async_update(self): + """Synchronize state with controller.""" + await self.async_request_controller_update(self.client.mac) + + @property + def name(self): + """Return the name of the switch.""" + return self.client.hostname + + @property + def unique_id(self): + """Return a unique identifier for this switch.""" + return 'poe-{}'.format(self.client.mac) + + @property + def is_on(self): + """Return true if POE is active.""" + return self.port.poe_mode != 'off' + + @property + def available(self): + """Return if switch is available.""" + return self.controller.available or \ + self.client.sw_mac in self.controller.api.devices + + async def async_turn_on(self, **kwargs): + """Enable POE for client.""" + await self.device.async_set_port_poe_mode( + self.client.sw_port, self.poe_mode) + + async def async_turn_off(self, **kwargs): + """Disable POE for client.""" + await self.device.async_set_port_poe_mode(self.client.sw_port, 'off') + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = { + 'power': self.port.poe_power, + 'received': self.client.wired_rx_bytes / 1000000, + 'sent': self.client.wired_tx_bytes / 1000000, + 'switch': self.client.sw_mac, + 'port': self.client.sw_port, + 'poe_mode': self.poe_mode + } + return attributes + + @property + def device_info(self): + """Return a device description for device registry.""" + return { + 'connections': {(CONNECTION_NETWORK_MAC, self.client.mac)} + } + + @property + def device(self): + """Shortcut to the switch that client is connected to.""" + return self.controller.api.devices[self.client.sw_mac] + + @property + def port(self): + """Shortcut to the switch port that client is connected to.""" + return self.device.ports[self.client.sw_port] diff --git a/homeassistant/components/switch/xiaomi_aqara.py b/homeassistant/components/switch/xiaomi_aqara.py index 17265d5dfa2..d5502c0b6fa 100644 --- a/homeassistant/components/switch/xiaomi_aqara.py +++ b/homeassistant/components/switch/xiaomi_aqara.py @@ -16,6 +16,7 @@ ATTR_IN_USE = 'in_use' LOAD_POWER = 'load_power' POWER_CONSUMED = 'power_consumed' +ENERGY_CONSUMED = 'energy_consumed' IN_USE = 'inuse' @@ -57,8 +58,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 'channel_1', False, gateway)) elif model in ['86plug', 'ctrl_86plug', 'ctrl_86plug.aq1']: + if 'proto' not in device or int(device['proto'][0:1]) == 1: + data_key = 'status' + else: + data_key = 'channel_0' devices.append(XiaomiGenericSwitch(device, 'Wall Plug', - 'status', True, gateway)) + data_key, True, gateway)) add_entities(devices) @@ -122,13 +127,17 @@ class XiaomiGenericSwitch(XiaomiDevice, SwitchDevice): self._in_use = int(data[IN_USE]) if not self._in_use: self._load_power = 0 - if POWER_CONSUMED in data: - self._power_consumed = round(float(data[POWER_CONSUMED]), 2) + + for key in [POWER_CONSUMED, ENERGY_CONSUMED]: + if key in data: + self._power_consumed = round(float(data[key]), 2) + break + if LOAD_POWER in data: self._load_power = round(float(data[LOAD_POWER]), 2) value = data.get(self._data_key) - if value is None: + if value not in ['on', 'off']: return False state = value == 'on' diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index 821de5bf647..0152615109c 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -10,12 +10,14 @@ import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA, - DOMAIN, ) -from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, - ATTR_ENTITY_ID, ) +from homeassistant.components.switch import ( + DOMAIN, PLATFORM_SCHEMA, SwitchDevice) +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN) from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['python-miio==0.4.2', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) @@ -39,8 +41,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 'chuangmi.plug.v3']), }) -REQUIREMENTS = ['python-miio==0.4.1', 'construct==2.9.41'] - ATTR_POWER = 'power' ATTR_TEMPERATURE = 'temperature' ATTR_LOAD_POWER = 'load_power' diff --git a/homeassistant/components/switch/zwave.py b/homeassistant/components/switch/zwave.py index 31f942bd3af..54a2a729d04 100644 --- a/homeassistant/components/switch/zwave.py +++ b/homeassistant/components/switch/zwave.py @@ -6,13 +6,30 @@ https://home-assistant.io/components/switch.zwave/ """ import logging import time +from homeassistant.core import callback from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.components import zwave -from homeassistant.components.zwave import workaround, async_setup_platform # noqa pylint: disable=unused-import +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old method of setting up Z-Wave switches.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Z-Wave Switch from Config Entry.""" + @callback + def async_add_switch(switch): + """Add Z-Wave Switch.""" + async_add_entities([switch]) + + async_dispatcher_connect(hass, 'zwave_new_switch', async_add_switch) + + def get_device(values, **kwargs): """Create zwave entity device.""" return ZwaveSwitch(values) @@ -25,8 +42,8 @@ class ZwaveSwitch(zwave.ZWaveDeviceEntity, SwitchDevice): """Initialize the Z-Wave switch device.""" zwave.ZWaveDeviceEntity.__init__(self, values, DOMAIN) self.refresh_on_update = ( - workaround.get_device_mapping(values.primary) == - workaround.WORKAROUND_REFRESH_NODE_ON_UPDATE) + zwave.workaround.get_device_mapping(values.primary) == + zwave.workaround.WORKAROUND_REFRESH_NODE_ON_UPDATE) self.last_update = time.perf_counter() self._state = self.values.primary.data diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 29781f5052c..28bc7a1ad0d 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -21,7 +21,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.exceptions import TemplateError from homeassistant.setup import async_prepare_setup_platform -REQUIREMENTS = ['python-telegram-bot==11.0.0'] +REQUIREMENTS = ['python-telegram-bot==11.1.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tradfri/.translations/pt.json b/homeassistant/components/tradfri/.translations/pt.json index 05d3cbb57fe..e89cb6ac620 100644 --- a/homeassistant/components/tradfri/.translations/pt.json +++ b/homeassistant/components/tradfri/.translations/pt.json @@ -18,6 +18,6 @@ "title": "Introduzir c\u00f3digo de seguran\u00e7a" } }, - "title": "" + "title": "IKEA TR\u00c5DFRI" } } \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/ro.json b/homeassistant/components/tradfri/.translations/ro.json new file mode 100644 index 00000000000..cea0e6d938f --- /dev/null +++ b/homeassistant/components/tradfri/.translations/ro.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge-ul este deja configurat" + }, + "error": { + "cannot_connect": "Nu se poate conecta la gateway.", + "invalid_key": "Nu s-a \u00eenregistrat cu cheia furnizat\u0103. Dac\u0103 acest lucru se \u00eent\u00e2mpl\u0103 \u00een continuare, \u00eencerca\u021bi s\u0103 reporni\u021bi gateway-ul.", + "timeout": "Timeout la validarea codului." + }, + "step": { + "auth": { + "data": { + "host": "Gazd\u0103", + "security_code": "Cod de securitate" + }, + "description": "Pute\u021bi g\u0103si codul de securitate pe spatele gateway-ului.", + "title": "Introduce\u021bi codul de securitate" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tts/amazon_polly.py b/homeassistant/components/tts/amazon_polly.py index d59331984b7..7b3fe4ef04e 100644 --- a/homeassistant/components/tts/amazon_polly.py +++ b/homeassistant/components/tts/amazon_polly.py @@ -11,7 +11,7 @@ from homeassistant.components.tts import Provider, PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['boto3==1.4.7'] +REQUIREMENTS = ['boto3==1.9.16'] CONF_REGION = 'region_name' CONF_ACCESS_KEY_ID = 'aws_access_key_id' @@ -87,7 +87,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def get_engine(hass, config): """Set up Amazon Polly speech component.""" - # pylint: disable=import-error output_format = config.get(CONF_OUTPUT_FORMAT) sample_rate = config.get(CONF_SAMPLE_RATE, DEFAULT_SAMPLE_RATES[output_format]) diff --git a/homeassistant/components/twilio.py b/homeassistant/components/twilio.py index 9f32a44ce7e..9f9767e4675 100644 --- a/homeassistant/components/twilio.py +++ b/homeassistant/components/twilio.py @@ -10,7 +10,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.core import callback from homeassistant.components.http import HomeAssistantView -REQUIREMENTS = ['twilio==5.7.0'] +REQUIREMENTS = ['twilio==6.19.1'] DOMAIN = 'twilio' @@ -51,8 +51,8 @@ class TwilioReceiveDataView(HomeAssistantView): @callback def post(self, request): # pylint: disable=no-self-use """Handle Twilio data post.""" - from twilio.twiml import Response + from twilio.twiml import TwiML hass = request.app['hass'] data = yield from request.post() hass.bus.async_fire(RECEIVED_DATA, dict(data)) - return Response().toxml() + return TwiML().to_xml() diff --git a/homeassistant/components/twilio/.translations/ca.json b/homeassistant/components/twilio/.translations/ca.json new file mode 100644 index 00000000000..3179f420ede --- /dev/null +++ b/homeassistant/components/twilio/.translations/ca.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "La vostra inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de Twilio.", + "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." + }, + "create_entry": { + "default": "Per enviar esdeveniments a Home Assistant, haureu de configurar [Webhooks amb Twilio] ({twilio_url}). \n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - M\u00e8tode: POST \n - Tipus de contingut: application/x-www-form-urlencoded\n\nConsulteu [la documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar les dades entrants." + }, + "step": { + "user": { + "description": "Esteu segur que voleu configurar Twilio?", + "title": "Configureu el Webhook de Twilio" + } + }, + "title": "Twilio" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/en.json b/homeassistant/components/twilio/.translations/en.json new file mode 100644 index 00000000000..3ee0421469c --- /dev/null +++ b/homeassistant/components/twilio/.translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Twilio messages.", + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to setup [Webhooks with Twilio]({twilio_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + }, + "step": { + "user": { + "description": "Are you sure you want to set up Twilio?", + "title": "Set up the Twilio Webhook" + } + }, + "title": "Twilio" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/ko.json b/homeassistant/components/twilio/.translations/ko.json new file mode 100644 index 00000000000..028919bff90 --- /dev/null +++ b/homeassistant/components/twilio/.translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Twilio \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c\ud569\ub2c8\ub2e4.", + "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." + }, + "create_entry": { + "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Twilio Webhook]({twilio_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/x-www-form-urlencoded\n \n Home Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." + }, + "step": { + "user": { + "description": "Twilio \uc744 \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Twilio Webhook \uc124\uc815" + } + }, + "title": "Twilio" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/nl.json b/homeassistant/components/twilio/.translations/nl.json new file mode 100644 index 00000000000..a053bf372a5 --- /dev/null +++ b/homeassistant/components/twilio/.translations/nl.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Slechts \u00e9\u00e9n exemplaar is nodig." + }, + "step": { + "user": { + "description": "Weet u zeker dat u Twilio wilt instellen?", + "title": "Stel de Twilio Webhook in" + } + }, + "title": "Twilio" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/no.json b/homeassistant/components/twilio/.translations/no.json new file mode 100644 index 00000000000..86e5d9051b3 --- /dev/null +++ b/homeassistant/components/twilio/.translations/no.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Twilio" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/pl.json b/homeassistant/components/twilio/.translations/pl.json new file mode 100644 index 00000000000..19c835c4b8c --- /dev/null +++ b/homeassistant/components/twilio/.translations/pl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Tw\u00f3j Home Assistant musi by\u0107 dost\u0119pny z Internetu, aby odbiera\u0107 komunikaty Twilio.", + "one_instance_allowed": "Wymagana jest tylko jedna instancja." + }, + "create_entry": { + "default": "Aby wysy\u0142a\u0107 zdarzenia do Home Assistant'a, musisz skonfigurowa\u0107 [Twilio Webhook]({twilio_url}). \n\n Wprowad\u017a nast\u0119puj\u0105ce dane:\n\n - URL: `{webhook_url}` \n - Metoda: POST \n - Typ zawarto\u015bci: application/x-www-form-urlencoded \n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}) na temat konfiguracji automatyzacji by obs\u0142u\u017cy\u0107 przychodz\u0105ce dane." + }, + "step": { + "user": { + "description": "Czy chcesz skonfigurowa\u0107 Twilio?", + "title": "Konfiguracja Twilio Webhook" + } + }, + "title": "Twilio" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/ru.json b/homeassistant/components/twilio/.translations/ru.json new file mode 100644 index 00000000000..e758a47064e --- /dev/null +++ b/homeassistant/components/twilio/.translations/ru.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u0412\u0430\u0448 Home Assistant \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u0437 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0430 \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 Twilio.", + "one_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "create_entry": { + "default": "\u0414\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0432 Home Assistant \u0432\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c [Webhooks \u0434\u043b\u044f Twilio]({twilio_url}).\n\n\u0417\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({docs_url}) \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0437\u0430\u0446\u0438\u0439 \u043f\u043e \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0435 \u043f\u043e\u0441\u0442\u0443\u043f\u0430\u044e\u0449\u0438\u0445 \u0434\u0430\u043d\u043d\u044b\u0445." + }, + "step": { + "user": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Twilio?", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Twilio Webhook" + } + }, + "title": "Twilio" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/sl.json b/homeassistant/components/twilio/.translations/sl.json new file mode 100644 index 00000000000..0321cb05452 --- /dev/null +++ b/homeassistant/components/twilio/.translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": " \u010ce \u017eelite prejemati sporo\u010dila Twilio, mora biti Home Assistent dostopen prek interneta.", + "one_instance_allowed": "Potrebna je samo ena instanca." + }, + "create_entry": { + "default": "Za po\u0161iljanje dogodkov Home Assistent-u, boste morali nastaviti [Webhooks z Twilio]({twilio_url}).\n\nIzpolnite naslednje informacije:\n\n- URL: `{webhook_url}`\n- Metoda: POST\n- Vrsta vsebine: application/x-www-form-urlencoded\n\nGlej [dokumentacijo]({docs_url}) o tem, kako nastavite avtomatizacijo za obravnavo dohodnih podatkov." + }, + "step": { + "user": { + "description": "Ali ste prepri\u010dani, da \u017eelite nastaviti Twilio?", + "title": "Nastavite Twilio Webhook" + } + }, + "title": "Twilio" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/zh-Hant.json b/homeassistant/components/twilio/.translations/zh-Hant.json new file mode 100644 index 00000000000..2e85ef7b2de --- /dev/null +++ b/homeassistant/components/twilio/.translations/zh-Hant.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant \u5be6\u4f8b\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 Twilio \u8a0a\u606f\u3002", + "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002" + }, + "create_entry": { + "default": "\u6b32\u50b3\u9001\u4e8b\u4ef6\u81f3 Home Assistant\uff0c\u5c07\u9700\u8a2d\u5b9a [Webhooks with Twilio]({twilio_url})\u3002\n\n\u8acb\u586b\u5beb\u4e0b\u5217\u8cc7\u8a0a\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\n\u95dc\u65bc\u5982\u4f55\u50b3\u5165\u8cc7\u6599\u81ea\u52d5\u5316\u8a2d\u5b9a\uff0c\u8acb\u53c3\u95b1[\u6587\u4ef6]({docs_url})\u4ee5\u9032\u884c\u4e86\u89e3\u3002" + }, + "step": { + "user": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Twilio\uff1f", + "title": "\u8a2d\u5b9a Twilio Webhook" + } + }, + "title": "Twilio" + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/es.json b/homeassistant/components/unifi/.translations/es.json new file mode 100644 index 00000000000..4f570fe1386 --- /dev/null +++ b/homeassistant/components/unifi/.translations/es.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "El sitio del controlador ya est\u00e1 configurado", + "user_privilege": "El usuario debe ser administrador" + }, + "error": { + "faulty_credentials": "Credenciales de usuario incorrectas", + "service_unavailable": "Servicio No disponible" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Contrase\u00f1a", + "port": "Puerto", + "site": "ID del sitio", + "username": "Nombre de usuario", + "verify_ssl": "Controlador usando el certificado adecuado" + }, + "title": "Configurar el controlador UniFi" + } + }, + "title": "Controlador UniFi" + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/fr.json b/homeassistant/components/unifi/.translations/fr.json new file mode 100644 index 00000000000..68e90811a3e --- /dev/null +++ b/homeassistant/components/unifi/.translations/fr.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "user_privilege": "L'utilisateur doit \u00eatre administrateur" + }, + "error": { + "faulty_credentials": "Mauvaises informations d'identification de l'utilisateur", + "service_unavailable": "Aucun service disponible" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "password": "Mot de passe", + "port": "Port", + "site": "ID du site", + "username": "Nom d'utilisateur" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/hu.json b/homeassistant/components/unifi/.translations/hu.json index f5827c47353..06104c6ed6c 100644 --- a/homeassistant/components/unifi/.translations/hu.json +++ b/homeassistant/components/unifi/.translations/hu.json @@ -1,10 +1,18 @@ { "config": { + "abort": { + "user_privilege": "A felhaszn\u00e1l\u00f3nak rendszergazd\u00e1nak kell lennie" + }, + "error": { + "faulty_credentials": "Rossz felhaszn\u00e1l\u00f3i hiteles\u00edt\u0151 adatok", + "service_unavailable": "Nincs el\u00e9rhet\u0151 szolg\u00e1ltat\u00e1s" + }, "step": { "user": { "data": { "password": "Jelsz\u00f3", - "port": "Port" + "port": "Port", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } } } diff --git a/homeassistant/components/unifi/.translations/nl.json b/homeassistant/components/unifi/.translations/nl.json new file mode 100644 index 00000000000..7a1eea546a2 --- /dev/null +++ b/homeassistant/components/unifi/.translations/nl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Controller site is al geconfigureerd", + "user_privilege": "Gebruiker moet beheerder zijn" + }, + "error": { + "faulty_credentials": "Foutieve gebruikersgegevens", + "service_unavailable": "Geen service beschikbaar" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Wachtwoord", + "port": "Poort", + "site": "Site ID", + "username": "Gebruikersnaam", + "verify_ssl": "Controller gebruik van het juiste certificaat" + }, + "title": "Stel de UniFi-controller in" + } + }, + "title": "UniFi-controller" + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/no.json b/homeassistant/components/unifi/.translations/no.json new file mode 100644 index 00000000000..7e9251dc026 --- /dev/null +++ b/homeassistant/components/unifi/.translations/no.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Passord", + "port": "Port", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/pl.json b/homeassistant/components/unifi/.translations/pl.json index f2f8082ac76..5382adcbf7d 100644 --- a/homeassistant/components/unifi/.translations/pl.json +++ b/homeassistant/components/unifi/.translations/pl.json @@ -18,7 +18,7 @@ "username": "Nazwa u\u017cytkownika", "verify_ssl": "Kontroler u\u017cywa prawid\u0142owego certyfikatu" }, - "title": "Skonfiguruj kontroler UniFi" + "title": "Konfiguracja kontrolera UniFi" } }, "title": "Kontroler UniFi" diff --git a/homeassistant/components/unifi/.translations/pt.json b/homeassistant/components/unifi/.translations/pt.json new file mode 100644 index 00000000000..6730a3d258e --- /dev/null +++ b/homeassistant/components/unifi/.translations/pt.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "O site do controlador j\u00e1 se encontra configurado", + "user_privilege": "Utilizador tem que ser administrador" + }, + "error": { + "faulty_credentials": "Credenciais do utilizador erradas", + "service_unavailable": "Nenhum servi\u00e7o dispon\u00edvel" + }, + "step": { + "user": { + "data": { + "host": "Servidor", + "password": "Palavra-passe", + "port": "Porto", + "site": "Site ID", + "username": "Nome do utilizador", + "verify_ssl": "Controlador com certificados adequados" + }, + "title": "Configurar o controlador UniFi" + } + }, + "title": "Controlador UniFi" + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/ro.json b/homeassistant/components/unifi/.translations/ro.json new file mode 100644 index 00000000000..99b1ac57e0b --- /dev/null +++ b/homeassistant/components/unifi/.translations/ro.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "user_privilege": "Utilizatorul trebuie s\u0103 fie administrator" + }, + "error": { + "faulty_credentials": "Credentiale utilizator invalide", + "service_unavailable": "Nici un serviciu disponibil" + }, + "step": { + "user": { + "data": { + "host": "Gazd\u0103", + "password": "Parol\u0103", + "port": "Port", + "username": "Nume de utilizator", + "verify_ssl": "Controler utiliz\u00e2nd certificatul adecvat" + }, + "title": "Configura\u021bi un controler UniFi" + } + }, + "title": "Controler UniFi" + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/tr.json b/homeassistant/components/unifi/.translations/tr.json new file mode 100644 index 00000000000..667a5e676fb --- /dev/null +++ b/homeassistant/components/unifi/.translations/tr.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 ad\u0131" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py new file mode 100644 index 00000000000..26b60aecf42 --- /dev/null +++ b/homeassistant/components/unifi/__init__.py @@ -0,0 +1,186 @@ +""" +Support for devices connected to UniFi POE. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/unifi/ +""" + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL) +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC + +from .const import (CONF_CONTROLLER, CONF_POE_CONTROL, CONF_SITE_ID, + CONTROLLER_ID, DOMAIN, LOGGER) +from .controller import UniFiController, get_controller +from .errors import ( + AlreadyConfigured, AuthenticationRequired, CannotConnect, UserLevel) + +DEFAULT_PORT = 8443 +DEFAULT_SITE_ID = 'default' +DEFAULT_VERIFY_SSL = False + +REQUIREMENTS = ['aiounifi==3'] + + +async def async_setup(hass, config): + """Component doesn't support configuration through configuration.yaml.""" + return True + + +async def async_setup_entry(hass, config_entry): + """Set up the UniFi component.""" + controller = UniFiController(hass, config_entry) + + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + controller_id = CONTROLLER_ID.format( + host=config_entry.data[CONF_CONTROLLER][CONF_HOST], + site=config_entry.data[CONF_CONTROLLER][CONF_SITE_ID] + ) + + if not await controller.async_setup(): + return False + + hass.data[DOMAIN][controller_id] = controller + + if controller.mac is None: + return True + + device_registry = await \ + hass.helpers.device_registry.async_get_registry() + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(CONNECTION_NETWORK_MAC, controller.mac)}, + manufacturer='Ubiquiti', + model="UniFi Controller", + name="UniFi Controller", + # sw_version=config.raw['swversion'], + ) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + controller_id = CONTROLLER_ID.format( + host=config_entry.data[CONF_CONTROLLER][CONF_HOST], + site=config_entry.data[CONF_CONTROLLER][CONF_SITE_ID] + ) + controller = hass.data[DOMAIN].pop(controller_id) + return await controller.async_reset() + + +@config_entries.HANDLERS.register(DOMAIN) +class UnifiFlowHandler(config_entries.ConfigFlow): + """Handle a UniFi config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize the UniFi flow.""" + self.config = None + self.desc = None + self.sites = None + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + + try: + self.config = { + CONF_HOST: user_input[CONF_HOST], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_PORT: user_input.get(CONF_PORT), + CONF_VERIFY_SSL: user_input.get(CONF_VERIFY_SSL), + CONF_SITE_ID: DEFAULT_SITE_ID, + } + controller = await get_controller(self.hass, **self.config) + + self.sites = await controller.sites() + + return await self.async_step_site() + + except AuthenticationRequired: + errors['base'] = 'faulty_credentials' + + except CannotConnect: + errors['base'] = 'service_unavailable' + + except Exception: # pylint: disable=broad-except + LOGGER.error( + 'Unknown error connecting with UniFi Controller at %s', + user_input[CONF_HOST]) + return self.async_abort(reason='unknown') + + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional( + CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + }), + errors=errors, + ) + + async def async_step_site(self, user_input=None): + """Select site to control.""" + errors = {} + + if user_input is not None: + + try: + desc = user_input.get(CONF_SITE_ID, self.desc) + for site in self.sites.values(): + if desc == site['desc']: + if site['role'] != 'admin': + raise UserLevel + self.config[CONF_SITE_ID] = site['name'] + break + + for entry in self._async_current_entries(): + controller = entry.data[CONF_CONTROLLER] + if controller[CONF_HOST] == self.config[CONF_HOST] and \ + controller[CONF_SITE_ID] == self.config[CONF_SITE_ID]: + raise AlreadyConfigured + + data = { + CONF_CONTROLLER: self.config, + CONF_POE_CONTROL: True + } + + return self.async_create_entry( + title=desc, + data=data + ) + + except AlreadyConfigured: + return self.async_abort(reason='already_configured') + + except UserLevel: + return self.async_abort(reason='user_privilege') + + if len(self.sites) == 1: + self.desc = next(iter(self.sites.values()))['desc'] + return await self.async_step_site(user_input={}) + + sites = [] + for site in self.sites.values(): + sites.append(site['desc']) + + return self.async_show_form( + step_id='site', + data_schema=vol.Schema({ + vol.Required(CONF_SITE_ID): vol.In(sites) + }), + errors=errors, + ) diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py new file mode 100644 index 00000000000..7250feec799 --- /dev/null +++ b/homeassistant/components/unifi/const.py @@ -0,0 +1,12 @@ +"""Constants for the UniFi component.""" + +import logging + +LOGGER = logging.getLogger('homeassistant.components.unifi') +DOMAIN = 'unifi' + +CONTROLLER_ID = '{host}-{site}' + +CONF_CONTROLLER = 'controller' +CONF_POE_CONTROL = 'poe_control' +CONF_SITE_ID = 'site' diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py new file mode 100644 index 00000000000..9e21956536f --- /dev/null +++ b/homeassistant/components/unifi/controller.py @@ -0,0 +1,131 @@ +"""UniFi Controller abstraction.""" + +import asyncio +import async_timeout + +from aiohttp import CookieJar + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST +from homeassistant.helpers import aiohttp_client + +from .const import CONF_CONTROLLER, CONF_POE_CONTROL, LOGGER +from .errors import AuthenticationRequired, CannotConnect + + +class UniFiController: + """Manages a single UniFi Controller.""" + + def __init__(self, hass, config_entry): + """Initialize the system.""" + self.hass = hass + self.config_entry = config_entry + self.available = True + self.api = None + self.progress = None + self._cancel_retry_setup = None + + @property + def host(self): + """Return the host of this controller.""" + return self.config_entry.data[CONF_CONTROLLER][CONF_HOST] + + @property + def mac(self): + """Return the mac address of this controller.""" + for client in self.api.clients.values(): + if self.host == client.ip: + return client.mac + return None + + async def async_setup(self, tries=0): + """Set up a UniFi controller.""" + hass = self.hass + + try: + self.api = await get_controller( + self.hass, **self.config_entry.data[CONF_CONTROLLER]) + await self.api.initialize() + + except CannotConnect: + retry_delay = 2 ** (tries + 1) + LOGGER.error("Error connecting to the UniFi controller. Retrying " + "in %d seconds", retry_delay) + + async def retry_setup(_now): + """Retry setup.""" + if await self.async_setup(tries + 1): + # This feels hacky, we should find a better way to do this + self.config_entry.state = config_entries.ENTRY_STATE_LOADED + + self._cancel_retry_setup = hass.helpers.event.async_call_later( + retry_delay, retry_setup) + + return False + + except Exception: # pylint: disable=broad-except + LOGGER.error( + 'Unknown error connecting with UniFi controller.') + return False + + if self.config_entry.data[CONF_POE_CONTROL]: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup( + self.config_entry, 'switch')) + + return True + + async def async_reset(self): + """Reset this controller to default state. + + Will cancel any scheduled setup retry and will unload + the config entry. + """ + # If we have a retry scheduled, we were never setup. + if self._cancel_retry_setup is not None: + self._cancel_retry_setup() + self._cancel_retry_setup = None + return True + + # If the authentication was wrong. + if self.api is None: + return True + + if self.config_entry.data[CONF_POE_CONTROL]: + return await self.hass.config_entries.async_forward_entry_unload( + self.config_entry, 'switch') + return True + + +async def get_controller( + hass, host, username, password, port, site, verify_ssl): + """Create a controller object and verify authentication.""" + import aiounifi + + if verify_ssl: + session = aiohttp_client.async_get_clientsession(hass) + else: + session = aiohttp_client.async_create_clientsession( + hass, verify_ssl=verify_ssl, cookie_jar=CookieJar(unsafe=True)) + + controller = aiounifi.Controller( + host, username=username, password=password, port=port, site=site, + websession=session + ) + + try: + with async_timeout.timeout(5): + await controller.login() + return controller + + except aiounifi.Unauthorized: + LOGGER.warning("Connected to UniFi at %s but not registered.", host) + raise AuthenticationRequired + + except (asyncio.TimeoutError, aiounifi.RequestError): + LOGGER.error("Error connecting to the UniFi controller at %s", host) + raise CannotConnect + + except aiounifi.AiounifiException: + LOGGER.exception('Unknown UniFi communication error occurred') + raise AuthenticationRequired diff --git a/homeassistant/components/unifi/errors.py b/homeassistant/components/unifi/errors.py new file mode 100644 index 00000000000..c90c4956312 --- /dev/null +++ b/homeassistant/components/unifi/errors.py @@ -0,0 +1,26 @@ +"""Errors for the UniFi component.""" +from homeassistant.exceptions import HomeAssistantError + + +class UnifiException(HomeAssistantError): + """Base class for UniFi exceptions.""" + + +class AlreadyConfigured(UnifiException): + """Controller is already configured.""" + + +class AuthenticationRequired(UnifiException): + """Unknown error occurred.""" + + +class CannotConnect(UnifiException): + """Unable to connect to the controller.""" + + +class LoginRequired(UnifiException): + """Component got logged out.""" + + +class UserLevel(UnifiException): + """User level too low.""" diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json new file mode 100644 index 00000000000..938ac058d22 --- /dev/null +++ b/homeassistant/components/unifi/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "title": "UniFi Controller", + "step": { + "user": { + "title": "Set up UniFi Controller", + "data": { + "host": "Host", + "username": "User name", + "password": "Password", + "port": "Port", + "site": "Site ID", + "verify_ssl": "Controller using proper certificate" + } + } + }, + "error": { + "faulty_credentials": "Bad user credentials", + "service_unavailable": "No service available" + }, + "abort": { + "already_configured": "Controller site is already configured", + "user_privilege": "User needs to be administrator" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/updater.py b/homeassistant/components/updater.py index 4e64e3be2e6..6f7b75cf549 100644 --- a/homeassistant/components/updater.py +++ b/homeassistant/components/updater.py @@ -4,9 +4,9 @@ Support to check for available updates. For more details about this component, please refer to the documentation at https://home-assistant.io/components/updater/ """ -# pylint: disable=no-name-in-module, import-error import asyncio from datetime import timedelta +# pylint: disable=import-error,no-name-in-module from distutils.version import StrictVersion import json import logging diff --git a/homeassistant/components/upnp/.translations/es.json b/homeassistant/components/upnp/.translations/es.json new file mode 100644 index 00000000000..e4cabf4cd50 --- /dev/null +++ b/homeassistant/components/upnp/.translations/es.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "UPnP / IGD ya est\u00e1 configurado", + "no_devices_discovered": "No se descubrieron UPnP / IGDs", + "no_sensors_or_port_mapping": "Habilitar al menos sensores o mapeo de puertos" + }, + "step": { + "init": { + "title": "UPnP / IGD" + }, + "user": { + "data": { + "enable_port_mapping": "Habilitar la asignaci\u00f3n de puertos para Home Assistant", + "enable_sensors": "A\u00f1adir sensores de tr\u00e1fico", + "igd": "UPnP / IGD" + }, + "title": "Opciones de configuraci\u00f3n para UPnP/IGD" + } + }, + "title": "UPnP / IGD" + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/hu.json b/homeassistant/components/upnp/.translations/hu.json new file mode 100644 index 00000000000..a2bf78a7f3e --- /dev/null +++ b/homeassistant/components/upnp/.translations/hu.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "init": { + "title": "UPnP/IGD" + }, + "user": { + "data": { + "igd": "UPnP/IGD" + }, + "title": "Az UPnP/IGD be\u00e1ll\u00edt\u00e1si lehet\u0151s\u00e9gei" + } + }, + "title": "UPnP/IGD" + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/pt.json b/homeassistant/components/upnp/.translations/pt.json new file mode 100644 index 00000000000..899a5def479 --- /dev/null +++ b/homeassistant/components/upnp/.translations/pt.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "UPnP/IGD j\u00e1 est\u00e1 configurado", + "no_devices_discovered": "Nenhum UPnP/IGDs descoberto", + "no_sensors_or_port_mapping": "Ative pelo menos os sensores ou o mapeamento de porta" + }, + "error": { + "one": "um", + "other": "v\u00e1rios" + }, + "step": { + "init": { + "title": "UPnP/IGD" + }, + "user": { + "data": { + "enable_port_mapping": "Ativar o mapeamento de porta para o Home Assistant", + "enable_sensors": "Adicionar sensores de tr\u00e1fego", + "igd": "UPnP/IGD" + }, + "title": "Op\u00e7\u00f5es de configura\u00e7\u00e3o para o UPnP/IGD" + } + }, + "title": "UPnP/IGD" + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/tr.json b/homeassistant/components/upnp/.translations/tr.json new file mode 100644 index 00000000000..91503c17a07 --- /dev/null +++ b/homeassistant/components/upnp/.translations/tr.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "enable_sensors": "Trafik sens\u00f6rleri ekleyin" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index c496caba948..07c8d5f748e 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -30,7 +30,7 @@ from .config_flow import ensure_domain_data from .device import Device -REQUIREMENTS = ['async-upnp-client==0.12.6'] +REQUIREMENTS = ['async-upnp-client==0.12.7'] DEPENDENCIES = ['http'] NOTIFICATION_ID = 'upnp_notification' @@ -43,8 +43,8 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_LOCAL_IP): vol.All(ip_address, cv.string), vol.Optional(CONF_PORTS): vol.Schema({ - vol.Any(CONF_HASS, cv.positive_int): - vol.Any(CONF_HASS, cv.positive_int) + vol.Any(CONF_HASS, cv.port): + vol.Any(CONF_HASS, cv.port) }) }), }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/vacuum/neato.py b/homeassistant/components/vacuum/neato.py index 29db94de762..83a4ad7c58d 100644 --- a/homeassistant/components/vacuum/neato.py +++ b/homeassistant/components/vacuum/neato.py @@ -64,6 +64,9 @@ class NeatoConnectedVacuum(StateVacuumDevice): self.clean_battery_end = None self.clean_suspension_charge_count = None self.clean_suspension_time = None + self._available = False + self._battery_level = None + self._robot_serial = self.robot.serial def update(self): """Update the states of Neato Vacuums.""" @@ -71,12 +74,12 @@ class NeatoConnectedVacuum(StateVacuumDevice): self.neato.update_robots() try: self._state = self.robot.state + self._available = True except (requests.exceptions.ConnectionError, requests.exceptions.HTTPError) as ex: _LOGGER.warning("Neato connection error: %s", ex) self._state = None - self._clean_state = STATE_ERROR - self._status_state = 'Robot Offline' + self._available = False return _LOGGER.debug('self._state=%s', self._state) if self._state['state'] == 1: @@ -105,27 +108,30 @@ class NeatoConnectedVacuum(StateVacuumDevice): self._clean_state = STATE_ERROR self._status_state = ERRORS.get(self._state['error']) - if not self._mapdata.get(self.robot.serial, {}).get('maps', []): + if not self._mapdata.get(self._robot_serial, {}).get('maps', []): return self.clean_time_start = ( - (self._mapdata[self.robot.serial]['maps'][0]['start_at'] + (self._mapdata[self._robot_serial]['maps'][0]['start_at'] .strip('Z')) .replace('T', ' ')) self.clean_time_stop = ( - (self._mapdata[self.robot.serial]['maps'][0]['end_at'].strip('Z')) + (self._mapdata[self._robot_serial]['maps'][0]['end_at'].strip('Z')) .replace('T', ' ')) self.clean_area = ( - self._mapdata[self.robot.serial]['maps'][0]['cleaned_area']) + self._mapdata[self._robot_serial]['maps'][0]['cleaned_area']) self.clean_suspension_charge_count = ( - self._mapdata[self.robot.serial]['maps'][0] + self._mapdata[self._robot_serial]['maps'][0] ['suspended_cleaning_charging_count']) self.clean_suspension_time = ( - self._mapdata[self.robot.serial]['maps'][0] + self._mapdata[self._robot_serial]['maps'][0] ['time_in_suspended_cleaning']) self.clean_battery_start = ( - self._mapdata[self.robot.serial]['maps'][0]['run_charge_at_start']) + self._mapdata[self._robot_serial]['maps'][0]['run_charge_at_start'] + ) self.clean_battery_end = ( - self._mapdata[self.robot.serial]['maps'][0]['run_charge_at_end']) + self._mapdata[self._robot_serial]['maps'][0]['run_charge_at_end']) + + self._battery_level = self._state['details']['charge'] @property def name(self): @@ -140,13 +146,23 @@ class NeatoConnectedVacuum(StateVacuumDevice): @property def battery_level(self): """Return the battery level of the vacuum cleaner.""" - return self._state['details']['charge'] + return self._battery_level + + @property + def available(self): + """Return if the robot is available.""" + return self._available @property def state(self): """Return the status of the vacuum cleaner.""" return self._clean_state + @property + def unique_id(self): + """Return a unique ID.""" + return self._robot_serial + @property def device_state_attributes(self): """Return the state attributes of the vacuum cleaner.""" diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index d2da4f3b6ac..d1d45f5ecd2 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-miio==0.4.1', 'construct==2.9.41'] +REQUIREMENTS = ['python-miio==0.4.2', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) @@ -179,6 +179,10 @@ class MiroboVacuum(StateVacuumDevice): def state(self): """Return the status of the vacuum cleaner.""" if self.vacuum_state is not None: + # The vacuum reverts back to an idle state after erroring out. + # We want to keep returning an error until it has been cleared. + if self.vacuum_state.got_error: + return STATE_ERROR try: return STATE_CODE_TO_STATE[int(self.vacuum_state.state_code)] except KeyError: diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index 5bc6260c0a7..127cd008a3a 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -19,7 +19,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_LIGHTS, CONF_EXCLUDE) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyvera==0.2.44'] +REQUIREMENTS = ['pyvera==0.2.45'] _LOGGER = logging.getLogger(__name__) @@ -195,3 +195,11 @@ class VeraDevice(Entity): attr['Vera Device Id'] = self.vera_device.vera_device_id return attr + + @property + def unique_id(self) -> str: + """Return a unique ID. + + The Vera assigns a unique and immutable ID number to each device. + """ + return str(self.vera_device.vera_device_id) diff --git a/homeassistant/components/verisure.py b/homeassistant/components/verisure.py index 016547697b9..2c8c34fa67d 100644 --- a/homeassistant/components/verisure.py +++ b/homeassistant/components/verisure.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['vsure==1.3.7', 'jsonpath==0.75'] +REQUIREMENTS = ['vsure==1.5.0', 'jsonpath==0.75'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py new file mode 100644 index 00000000000..92dbebc4421 --- /dev/null +++ b/homeassistant/components/water_heater/__init__.py @@ -0,0 +1,263 @@ +""" +Provides functionality to interact with water heater devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/water_heater/ +""" +from datetime import timedelta +import logging +import functools as ft + +import voluptuous as vol + +from homeassistant.helpers.temperature import display_temp as show_temp +from homeassistant.util.temperature import convert as convert_temperature +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_TURN_ON, SERVICE_TURN_OFF, + STATE_ON, STATE_OFF, TEMP_CELSIUS, PRECISION_WHOLE, + PRECISION_TENTHS, TEMP_FAHRENHEIT) + +DEFAULT_MIN_TEMP = 110 +DEFAULT_MAX_TEMP = 140 + +DOMAIN = 'water_heater' + +ENTITY_ID_FORMAT = DOMAIN + '.{}' +SCAN_INTERVAL = timedelta(seconds=60) + +SERVICE_SET_AWAY_MODE = 'set_away_mode' +SERVICE_SET_TEMPERATURE = 'set_temperature' +SERVICE_SET_OPERATION_MODE = 'set_operation_mode' + +STATE_ECO = 'eco' +STATE_ELECTRIC = 'electric' +STATE_PERFORMANCE = 'performance' +STATE_HIGH_DEMAND = 'high_demand' +STATE_HEAT_PUMP = 'heat_pump' +STATE_GAS = 'gas' + +SUPPORT_TARGET_TEMPERATURE = 1 +SUPPORT_OPERATION_MODE = 2 +SUPPORT_AWAY_MODE = 4 + +ATTR_MAX_TEMP = 'max_temp' +ATTR_MIN_TEMP = 'min_temp' +ATTR_AWAY_MODE = 'away_mode' +ATTR_OPERATION_MODE = 'operation_mode' +ATTR_OPERATION_LIST = 'operation_list' + +CONVERTIBLE_ATTRIBUTE = [ + ATTR_TEMPERATURE, +] + +_LOGGER = logging.getLogger(__name__) + +ON_OFF_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +SET_AWAY_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_AWAY_MODE): cv.boolean, +}) +SET_TEMPERATURE_SCHEMA = vol.Schema(vol.All( + { + vol.Required(ATTR_TEMPERATURE, 'temperature'): vol.Coerce(float), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_OPERATION_MODE): cv.string, + } +)) +SET_OPERATION_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_OPERATION_MODE): cv.string, +}) + + +async def async_setup(hass, config): + """Set up water_heater devices.""" + component = hass.data[DOMAIN] = \ + EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_SET_AWAY_MODE, SET_AWAY_MODE_SCHEMA, + async_service_away_mode + ) + component.async_register_entity_service( + SERVICE_SET_TEMPERATURE, SET_TEMPERATURE_SCHEMA, + async_service_temperature_set + ) + component.async_register_entity_service( + SERVICE_SET_OPERATION_MODE, SET_OPERATION_MODE_SCHEMA, + 'async_set_operation_mode' + ) + component.async_register_entity_service( + SERVICE_TURN_OFF, ON_OFF_SERVICE_SCHEMA, + 'async_turn_off' + ) + component.async_register_entity_service( + SERVICE_TURN_ON, ON_OFF_SERVICE_SCHEMA, + 'async_turn_on' + ) + + return True + + +async def async_setup_entry(hass, entry): + """Set up a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + +class WaterHeaterDevice(Entity): + """Representation of a water_heater device.""" + + @property + def state(self): + """Return the current state.""" + return self.current_operation + + @property + def precision(self): + """Return the precision of the system.""" + if self.hass.config.units.temperature_unit == TEMP_CELSIUS: + return PRECISION_TENTHS + return PRECISION_WHOLE + + @property + def state_attributes(self): + """Return the optional state attributes.""" + data = { + ATTR_MIN_TEMP: show_temp( + self.hass, self.min_temp, self.temperature_unit, + self.precision), + ATTR_MAX_TEMP: show_temp( + self.hass, self.max_temp, self.temperature_unit, + self.precision), + ATTR_TEMPERATURE: show_temp( + self.hass, self.target_temperature, self.temperature_unit, + self.precision), + } + + supported_features = self.supported_features + + if supported_features & SUPPORT_OPERATION_MODE: + data[ATTR_OPERATION_MODE] = self.current_operation + if self.operation_list: + data[ATTR_OPERATION_LIST] = self.operation_list + + if supported_features & SUPPORT_AWAY_MODE: + is_away = self.is_away_mode_on + data[ATTR_AWAY_MODE] = STATE_ON if is_away else STATE_OFF + + return data + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + raise NotImplementedError + + @property + def current_operation(self): + """Return current operation ie. eco, electric, performance, ...""" + return None + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return None + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return None + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return None + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + raise NotImplementedError() + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + await self.hass.async_add_executor_job( + ft.partial(self.set_temperature, **kwargs)) + + def set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + raise NotImplementedError() + + async def async_set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + await self.hass.async_add_executor_job(self.set_operation_mode, + operation_mode) + + def turn_away_mode_on(self): + """Turn away mode on.""" + raise NotImplementedError() + + async def async_turn_away_mode_on(self): + """Turn away mode on.""" + await self.hass.async_add_executor_job(self.turn_away_mode_on) + + def turn_away_mode_off(self): + """Turn away mode off.""" + raise NotImplementedError() + + async def async_turn_away_mode_off(self): + """Turn away mode off.""" + await self.hass.async_add_executor_job(self.turn_away_mode_off) + + @property + def supported_features(self): + """Return the list of supported features.""" + raise NotImplementedError() + + @property + def min_temp(self): + """Return the minimum temperature.""" + return convert_temperature(DEFAULT_MIN_TEMP, TEMP_FAHRENHEIT, + self.temperature_unit) + + @property + def max_temp(self): + """Return the maximum temperature.""" + return convert_temperature(DEFAULT_MAX_TEMP, TEMP_FAHRENHEIT, + self.temperature_unit) + + +async def async_service_away_mode(entity, service): + """Handle away mode service.""" + if service.data[ATTR_AWAY_MODE]: + await entity.async_turn_away_mode_on() + else: + await entity.async_turn_away_mode_off() + + +async def async_service_temperature_set(entity, service): + """Handle set temperature service.""" + hass = entity.hass + kwargs = {} + + for value, temp in service.data.items(): + if value in CONVERTIBLE_ATTRIBUTE: + kwargs[value] = convert_temperature( + temp, + hass.config.units.temperature_unit, + entity.temperature_unit + ) + else: + kwargs[value] = temp + + await entity.async_set_temperature(**kwargs) diff --git a/homeassistant/components/water_heater/demo.py b/homeassistant/components/water_heater/demo.py new file mode 100644 index 00000000000..89b86c12af4 --- /dev/null +++ b/homeassistant/components/water_heater/demo.py @@ -0,0 +1,110 @@ +""" +Demo platform that offers a fake water_heater device. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/demo/ +""" +from homeassistant.components.water_heater import ( + WaterHeaterDevice, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_AWAY_MODE, + SUPPORT_OPERATION_MODE) +from homeassistant.const import TEMP_FAHRENHEIT, ATTR_TEMPERATURE, TEMP_CELSIUS + +SUPPORT_FLAGS_HEATER = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | + SUPPORT_AWAY_MODE) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Demo water_heater devices.""" + add_entities([ + DemoWaterHeater('Demo Water Heater', 119, + TEMP_FAHRENHEIT, False, 'eco'), + DemoWaterHeater('Demo Water Heater Celsius', 45, + TEMP_CELSIUS, True, 'eco') + + ]) + + +class DemoWaterHeater(WaterHeaterDevice): + """Representation of a demo water_heater device.""" + + def __init__(self, name, target_temperature, unit_of_measurement, + away, current_operation): + """Initialize the water_heater device.""" + self._name = name + self._support_flags = SUPPORT_FLAGS_HEATER + if target_temperature is not None: + self._support_flags = \ + self._support_flags | SUPPORT_TARGET_TEMPERATURE + if away is not None: + self._support_flags = self._support_flags | SUPPORT_AWAY_MODE + if current_operation is not None: + self._support_flags = self._support_flags | SUPPORT_OPERATION_MODE + self._target_temperature = target_temperature + self._unit_of_measurement = unit_of_measurement + self._away = away + self._current_operation = current_operation + self._operation_list = ['eco', 'electric', 'performance', + 'high_demand', 'heat_pump', 'gas', + 'off'] + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._support_flags + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the water_heater device.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return self._current_operation + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return self._operation_list + + @property + def is_away_mode_on(self): + """Return if away mode is on.""" + return self._away + + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + self._target_temperature = kwargs.get(ATTR_TEMPERATURE) + self.schedule_update_ha_state() + + def set_operation_mode(self, operation_mode): + """Set new operation mode.""" + self._current_operation = operation_mode + self.schedule_update_ha_state() + + def turn_away_mode_on(self): + """Turn away mode on.""" + self._away = True + self.schedule_update_ha_state() + + def turn_away_mode_off(self): + """Turn away mode off.""" + self._away = False + self.schedule_update_ha_state() diff --git a/homeassistant/components/climate/econet.py b/homeassistant/components/water_heater/econet.py similarity index 96% rename from homeassistant/components/climate/econet.py rename to homeassistant/components/water_heater/econet.py index 8be640c37e1..6af8ea43fa6 100644 --- a/homeassistant/components/climate/econet.py +++ b/homeassistant/components/water_heater/econet.py @@ -2,17 +2,17 @@ Support for Rheem EcoNet water heaters. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.econet/ +https://home-assistant.io/components/water_heater.econet/ """ import datetime import logging import voluptuous as vol -from homeassistant.components.climate import ( +from homeassistant.components.water_heater import ( DOMAIN, PLATFORM_SCHEMA, STATE_ECO, STATE_ELECTRIC, STATE_GAS, STATE_HEAT_PUMP, STATE_HIGH_DEMAND, STATE_OFF, STATE_PERFORMANCE, - SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, ClimateDevice) + SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, WaterHeaterDevice) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_PASSWORD, CONF_USERNAME, TEMP_FAHRENHEIT) @@ -109,7 +109,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): schema=DELETE_VACATION_SCHEMA) -class EcoNetWaterHeater(ClimateDevice): +class EcoNetWaterHeater(WaterHeaterDevice): """Representation of an EcoNet water heater.""" def __init__(self, water_heater): diff --git a/homeassistant/components/water_heater/services.yaml b/homeassistant/components/water_heater/services.yaml new file mode 100644 index 00000000000..72a3f909fbb --- /dev/null +++ b/homeassistant/components/water_heater/services.yaml @@ -0,0 +1,51 @@ +# Describes the format for available water_heater services + +set_away_mode: + description: Turn away mode on/off for water_heater device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'water_heater.water_heater' + away_mode: + description: New value of away mode. + example: true + +set_temperature: + description: Set target temperature of water_heater device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'water_heater.water_heater' + temperature: + description: New target temperature for water heater. + example: 25 + +set_operation_mode: + description: Set operation mode for water_heater device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'water_heater.water_heater' + operation_mode: + description: New value of operation mode. + example: eco + +econet_add_vacation: + description: Add a vacation to your water heater. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'water_heater.econet' + start_date: + description: The timestamp of when the vacation should start. (Optional, defaults to now) + example: 1513186320 + end_date: + description: The timestamp of when the vacation should end. + example: 1513445520 + +econet_delete_vacation: + description: Delete your existing vacation from your water heater. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'water_heater.econet' \ No newline at end of file diff --git a/homeassistant/components/water_heater/wink.py b/homeassistant/components/water_heater/wink.py new file mode 100644 index 00000000000..a840baf980a --- /dev/null +++ b/homeassistant/components/water_heater/wink.py @@ -0,0 +1,136 @@ +""" +Support for Wink water heaters. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/water_heater.wink/ +""" +import logging + +from homeassistant.components.water_heater import ( + ATTR_TEMPERATURE, STATE_ECO, STATE_ELECTRIC, + STATE_PERFORMANCE, SUPPORT_AWAY_MODE, STATE_HEAT_PUMP, + STATE_GAS, STATE_HIGH_DEMAND, + SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, + WaterHeaterDevice) +from homeassistant.components.wink import DOMAIN, WinkDevice +from homeassistant.const import ( + STATE_OFF, STATE_UNKNOWN, TEMP_CELSIUS) + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS_HEATER = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | + SUPPORT_AWAY_MODE) + +ATTR_RHEEM_TYPE = 'rheem_type' +ATTR_VACATION_MODE = 'vacation_mode' + +HA_STATE_TO_WINK = { + STATE_ECO: 'eco', + STATE_ELECTRIC: 'electric_only', + STATE_GAS: 'gas', + STATE_HEAT_PUMP: 'heat_pump', + STATE_HIGH_DEMAND: 'high_demand', + STATE_OFF: 'off', + STATE_PERFORMANCE: 'performance', +} + +WINK_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_WINK.items()} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Wink water heater devices.""" + import pywink + for water_heater in pywink.get_water_heaters(): + _id = water_heater.object_id() + water_heater.name() + if _id not in hass.data[DOMAIN]['unique_ids']: + add_entities([WinkWaterHeater(water_heater, hass)]) + + +class WinkWaterHeater(WinkDevice, WaterHeaterDevice): + """Representation of a Wink water heater.""" + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS_HEATER + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + # The Wink API always returns temp in Celsius + return TEMP_CELSIUS + + @property + def device_state_attributes(self): + """Return the optional device state attributes.""" + data = {} + data[ATTR_VACATION_MODE] = self.wink.vacation_mode_enabled() + data[ATTR_RHEEM_TYPE] = self.wink.rheem_type() + + return data + + @property + def current_operation(self): + """ + Return current operation one of the following. + + ["eco", "performance", "heat_pump", + "high_demand", "electric_only", "gas] + """ + if not self.wink.is_on(): + current_op = STATE_OFF + else: + current_op = WINK_STATE_TO_HA.get(self.wink.current_mode()) + if current_op is None: + current_op = STATE_UNKNOWN + return current_op + + @property + def operation_list(self): + """List of available operation modes.""" + op_list = ['off'] + modes = self.wink.modes() + for mode in modes: + if mode == 'aux': + continue + ha_mode = WINK_STATE_TO_HA.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + else: + error = "Invalid operation mode mapping. " + mode + \ + " doesn't map. Please report this." + _LOGGER.error(error) + return op_list + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + target_temp = kwargs.get(ATTR_TEMPERATURE) + self.wink.set_temperature(target_temp) + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode) + self.wink.set_operation_mode(op_mode_to_set) + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.wink.current_set_point() + + def turn_away_mode_on(self): + """Turn away on.""" + self.wink.set_vacation_mode(True) + + def turn_away_mode_off(self): + """Turn away off.""" + self.wink.set_vacation_mode(False) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self.wink.min_set_point() + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self.wink.max_set_point() diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index f9a8f1fbbe4..725c7f609a7 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -40,12 +40,21 @@ ATTR_WEATHER_WIND_SPEED = 'wind_speed' async def async_setup(hass, config): """Set up the weather component.""" - component = EntityComponent(_LOGGER, DOMAIN, hass) - + component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) await component.async_setup(config) return True +async def async_setup_entry(hass, entry): + """Set up a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + class WeatherEntity(Entity): """ABC for weather data.""" diff --git a/homeassistant/components/weather/met.py b/homeassistant/components/weather/met.py index f888af2e909..bab6624e9d0 100644 --- a/homeassistant/components/weather/met.py +++ b/homeassistant/components/weather/met.py @@ -16,9 +16,9 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import (async_track_utc_time_change, async_call_later) -from homeassistant.util import dt as dt_util +import homeassistant.util.dt as dt_util -REQUIREMENTS = ['pyMetno==0.2.0'] +REQUIREMENTS = ['pyMetno==0.3.0'] _LOGGER = logging.getLogger(__name__) @@ -26,49 +26,6 @@ CONF_ATTRIBUTION = "Weather forecast from met.no, delivered " \ "by the Norwegian Meteorological Institute." DEFAULT_NAME = "Met.no" -# https://api.met.no/weatherapi/weathericon/_/documentation/#___top -CONDITIONS = {1: 'sunny', - 2: 'partlycloudy', - 3: 'partlycloudy', - 4: 'cloudy', - 5: 'rainy', - 6: 'lightning-rainy', - 7: 'snowy-rainy', - 8: 'snowy', - 9: 'rainy', - 10: 'rainy', - 11: 'lightning-rainy', - 12: 'snowy-rainy', - 13: 'snowy', - 14: 'snowy', - 15: 'fog', - 20: 'lightning-rainy', - 21: 'lightning-rainy', - 22: 'lightning-rainy', - 23: 'lightning-rainy', - 24: 'lightning-rainy', - 25: 'lightning-rainy', - 26: 'lightning-rainy', - 27: 'lightning-rainy', - 28: 'lightning-rainy', - 29: 'lightning-rainy', - 30: 'lightning-rainy', - 31: 'lightning-rainy', - 32: 'lightning-rainy', - 33: 'lightning-rainy', - 34: 'lightning-rainy', - 40: 'rainy', - 41: 'rainy', - 42: 'snowy-rainy', - 43: 'snowy-rainy', - 44: 'snowy', - 45: 'snowy', - 46: 'rainy', - 47: 'snowy-rainy', - 48: 'snowy-rainy', - 49: 'snowy', - 50: 'snowy', - } URL = 'https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/1.9/' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -113,12 +70,8 @@ class MetWeather(WeatherEntity): clientsession, URL ) - self._temperature = None - self._condition = None - self._pressure = None - self._humidity = None - self._wind_speed = None - self._wind_bearing = None + self._current_weather_data = {} + self._forecast_data = None async def async_added_to_hass(self): """Start fetching data.""" @@ -145,38 +98,9 @@ class MetWeather(WeatherEntity): async def _update(self, *_): """Get the latest data from Met.no.""" - import metno - if self._weather_data is None: - return - - now = dt_util.utcnow() - - ordered_entries = [] - for time_entry in self._weather_data.data['product']['time']: - valid_from = dt_util.parse_datetime(time_entry['@from']) - valid_to = dt_util.parse_datetime(time_entry['@to']) - - if now >= valid_to: - # Has already passed. Never select this. - continue - - average_dist = (abs((valid_to - now).total_seconds()) + - abs((valid_from - now).total_seconds())) - - ordered_entries.append((average_dist, time_entry)) - - if not ordered_entries: - return - ordered_entries.sort(key=lambda item: item[0]) - - self._temperature = metno.get_forecast('temperature', ordered_entries) - self._condition = CONDITIONS.get(metno.get_forecast('symbol', - ordered_entries)) - self._pressure = metno.get_forecast('pressure', ordered_entries) - self._humidity = metno.get_forecast('humidity', ordered_entries) - self._wind_speed = metno.get_forecast('windSpeed', ordered_entries) - self._wind_bearing = metno.get_forecast('windDirection', - ordered_entries) + self._current_weather_data = self._weather_data.get_current_weather() + time_zone = dt_util.DEFAULT_TIME_ZONE + self._forecast_data = self._weather_data.get_forecast(time_zone) self.async_schedule_update_ha_state() @property @@ -187,12 +111,12 @@ class MetWeather(WeatherEntity): @property def condition(self): """Return the current condition.""" - return self._condition + return self._current_weather_data.get('condition') @property def temperature(self): """Return the temperature.""" - return self._temperature + return self._current_weather_data.get('temperature') @property def temperature_unit(self): @@ -202,24 +126,29 @@ class MetWeather(WeatherEntity): @property def pressure(self): """Return the pressure.""" - return self._pressure + return self._current_weather_data.get('pressure') @property def humidity(self): """Return the humidity.""" - return self._humidity + return self._current_weather_data.get('humidity') @property def wind_speed(self): """Return the wind speed.""" - return self._wind_speed + return self._current_weather_data.get('wind_speed') @property def wind_bearing(self): """Return the wind direction.""" - return self._wind_bearing + return self._current_weather_data.get('wind_bearing') @property def attribution(self): """Return the attribution.""" return CONF_ATTRIBUTION + + @property + def forecast(self): + """Return the forecast array.""" + return self._forecast_data diff --git a/homeassistant/components/weather/smhi.py b/homeassistant/components/weather/smhi.py new file mode 100644 index 00000000000..3bbaab3f8ec --- /dev/null +++ b/homeassistant/components/weather/smhi.py @@ -0,0 +1,242 @@ +"""Support for the Swedish weather institute weather service. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/weather.smhi/ +""" + + +import asyncio +import logging +from datetime import timedelta +from typing import Dict, List + +import aiohttp +import async_timeout + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_LATITUDE, CONF_LONGITUDE, + CONF_NAME, TEMP_CELSIUS) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.util import dt, Throttle + +from homeassistant.components.weather import ( + WeatherEntity, ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, + ATTR_FORECAST_PRECIPITATION) + +from homeassistant.components.smhi.const import ( + ENTITY_ID_SENSOR_FORMAT, ATTR_SMHI_CLOUDINESS) + +DEPENDENCIES = ['smhi'] + +_LOGGER = logging.getLogger(__name__) + +# Used to map condition from API results +CONDITION_CLASSES = { + 'cloudy': [5, 6], + 'fog': [7], + 'hail': [], + 'lightning': [21], + 'lightning-rainy': [11], + 'partlycloudy': [3, 4], + 'pouring': [10, 20], + 'rainy': [8, 9, 18, 19], + 'snowy': [15, 16, 17, 25, 26, 27], + 'snowy-rainy': [12, 13, 14, 22, 23, 24], + 'sunny': [1, 2], + 'windy': [], + 'windy-variant': [], + 'exceptional': [], +} + + +# 5 minutes between retrying connect to API again +RETRY_TIMEOUT = 5*60 + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=31) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Old way of setting up components. + + Can only be called when a user accidentally mentions smhi in the + config. In that case it will be ignored. + """ + pass + + +async def async_setup_entry(hass: HomeAssistant, + config_entry: ConfigEntry, + config_entries) -> bool: + """Add a weather entity from map location.""" + location = config_entry.data + name = location[CONF_NAME] + + session = aiohttp_client.async_get_clientsession(hass) + + entity = SmhiWeather(name, location[CONF_LATITUDE], + location[CONF_LONGITUDE], + session=session) + entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(name) + + config_entries([entity], True) + return True + + +class SmhiWeather(WeatherEntity): + """Representation of a weather entity.""" + + def __init__(self, name: str, latitude: str, + longitude: str, + session: aiohttp.ClientSession = None) -> None: + """Initialize the SMHI weather entity.""" + from smhi import Smhi + + self._name = name + self._latitude = latitude + self._longitude = longitude + self._forecasts = None + self._fail_count = 0 + self._smhi_api = Smhi(self._longitude, self._latitude, + session=session) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self) -> None: + """Refresh the forecast data from SMHI weather API.""" + from smhi.smhi_lib import SmhiForecastException + + def fail(): + self._fail_count += 1 + if self._fail_count < 3: + self.hass.helpers.event.async_call_later( + RETRY_TIMEOUT, self.retry_update()) + + try: + with async_timeout.timeout(10, loop=self.hass.loop): + self._forecasts = await self.get_weather_forecast() + self._fail_count = 0 + + except (asyncio.TimeoutError, SmhiForecastException): + _LOGGER.error("Failed to connect to SMHI API, " + "retry in 5 minutes") + fail() + + async def retry_update(self): + """Retry refresh weather forecast.""" + self.async_update() + + async def get_weather_forecast(self) -> []: + """Return the current forecasts from SMHI API.""" + return await self._smhi_api.async_get_forecast() + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def temperature(self) -> int: + """Return the temperature.""" + if self._forecasts is not None: + return self._forecasts[0].temperature + return None + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def humidity(self) -> int: + """Return the humidity.""" + if self._forecasts is not None: + return self._forecasts[0].humidity + return None + + @property + def wind_speed(self) -> float: + """Return the wind speed.""" + if self._forecasts is not None: + # Convert from m/s to km/h + return round(self._forecasts[0].wind_speed*18/5) + return None + + @property + def wind_bearing(self) -> int: + """Return the wind bearing.""" + if self._forecasts is not None: + return self._forecasts[0].wind_direction + return None + + @property + def visibility(self) -> float: + """Return the visibility.""" + if self._forecasts is not None: + return self._forecasts[0].horizontal_visibility + return None + + @property + def pressure(self) -> int: + """Return the pressure.""" + if self._forecasts is not None: + return self._forecasts[0].pressure + return None + + @property + def cloudiness(self) -> int: + """Return the cloudiness.""" + if self._forecasts is not None: + return self._forecasts[0].cloudiness + return None + + @property + def condition(self) -> str: + """Return the weather condition.""" + if self._forecasts is None: + return None + return next(( + k for k, v in CONDITION_CLASSES.items() + if self._forecasts[0].symbol in v), None) + + @property + def attribution(self) -> str: + """Return the attribution.""" + return 'Swedish weather institute (SMHI)' + + @property + def forecast(self) -> List: + """Return the forecast.""" + if self._forecasts is None: + return None + + data = [] + for forecast in self._forecasts: + condition = next(( + k for k, v in CONDITION_CLASSES.items() + if forecast.symbol in v), None) + + # Only get mid day forecasts + if forecast.valid_time.hour == 12: + data.append({ + ATTR_FORECAST_TIME: + dt.as_local(forecast.valid_time), + ATTR_FORECAST_TEMP: + forecast.temperature_max, + ATTR_FORECAST_TEMP_LOW: + forecast.temperature_min, + ATTR_FORECAST_PRECIPITATION: + round(forecast.mean_precipitation*24), + ATTR_FORECAST_CONDITION: + condition + }) + + return data + + @property + def device_state_attributes(self) -> Dict: + """Return SMHI specific attributes.""" + if self.cloudiness: + return {ATTR_SMHI_CLOUDINESS: self.cloudiness} diff --git a/homeassistant/components/weather/yweather.py b/homeassistant/components/weather/yweather.py index 505c287a99e..567b8e235a8 100644 --- a/homeassistant/components/weather/yweather.py +++ b/homeassistant/components/weather/yweather.py @@ -31,21 +31,21 @@ DEFAULT_NAME = 'Yweather' SCAN_INTERVAL = timedelta(minutes=10) CONDITION_CLASSES = { - 'clear-night': [31], - 'cloudy': [26, 27, 28, 29, 30], - 'fog': [19, 20, 21, 22, 23], - 'hail': [17, 18, 35], - 'lightning': [37], - 'lightning-rainy': [3, 4, 38, 39, 47], - 'partlycloudy': [44], - 'pouring': [40, 45], - 'rainy': [9, 11, 12], - 'snowy': [8, 13, 14, 15, 16, 41, 42, 43], - 'snowy-rainy': [5, 6, 7, 10, 46], - 'sunny': [32, 33, 34, 25, 36], - 'windy': [24], + 'clear-night': [31, 33], + 'cloudy': [26, 27, 28], + 'fog': [20, 21], + 'hail': [17, 35], + 'lightning': [], + 'lightning-rainy': [3, 4, 37, 38, 39, 45, 47], + 'partlycloudy': [29, 30, 44], + 'pouring': [], + 'rainy': [9, 10, 11, 12, 40], + 'snowy': [8, 13, 14, 15, 16, 41, 42, 43, 46], + 'snowy-rainy': [5, 6, 7, 18], + 'sunny': [25, 32, 34, 36], + 'windy': [23, 24], 'windy-variant': [], - 'exceptional': [0, 1, 2], + 'exceptional': [0, 1, 2, 19, 22], } diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index d21ccc18c93..3db044c4d1b 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -177,7 +177,7 @@ DIAL_STATE_SCHEMA = vol.Schema({ WINK_COMPONENTS = [ 'binary_sensor', 'sensor', 'light', 'switch', 'lock', 'cover', 'climate', - 'fan', 'alarm_control_panel', 'scene' + 'fan', 'alarm_control_panel', 'scene', 'water_heater' ] WINK_HUBS = [] diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index 27414a64150..aa2102ca805 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -22,7 +22,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow from homeassistant.util import slugify -REQUIREMENTS = ['PyXiaomiGateway==0.11.0'] +REQUIREMENTS = ['PyXiaomiGateway==0.11.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 2e74e079d5f..8cea746f89a 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -276,15 +276,23 @@ class ApplicationListener: device_classes, discovery_attr, is_new_join): """Try to set up an entity from a "bare" cluster.""" + import homeassistant.components.zha.const as zha_const if cluster.cluster_id in profile_clusters: return - component = None + component = sub_component = None for cluster_type, candidate_component in device_classes.items(): if isinstance(cluster, cluster_type): component = candidate_component break + for signature, comp in zha_const.CUSTOM_CLUSTER_MAPPINGS.items(): + if (isinstance(endpoint.device, signature[0]) and + cluster.cluster_id == signature[1]): + component = comp[0] + sub_component = comp[1] + break + if component is None: return @@ -301,6 +309,8 @@ class ApplicationListener: 'entity_suffix': '_{}'.format(cluster.cluster_id), } discovery_info[discovery_attr] = {cluster.cluster_id: cluster} + if sub_component: + discovery_info.update({'sub_component': sub_component}) self._hass.data[DISCOVERY_KEY][cluster_key] = discovery_info await discovery.async_load_platform( diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index 37c7f5592a0..0b3e926fadc 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -3,6 +3,7 @@ DEVICE_CLASS = {} SINGLE_INPUT_CLUSTER_DEVICE_CLASS = {} SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = {} +CUSTOM_CLUSTER_MAPPINGS = {} COMPONENT_CLUSTERS = {} @@ -12,8 +13,9 @@ def populate_data(): These cannot be module level, as importing bellows must be done in a in a function. """ - from zigpy import zcl + from zigpy import zcl, quirks from zigpy.profiles import PROFILES, zha, zll + from homeassistant.components.sensor import zha as sensor_zha DEVICE_CLASS[zha.PROFILE_ID] = { zha.DeviceType.ON_OFF_SWITCH: 'binary_sensor', @@ -58,6 +60,12 @@ def populate_data(): zcl.clusters.general.OnOff: 'binary_sensor', }) + # A map of device/cluster to component/sub-component + CUSTOM_CLUSTER_MAPPINGS.update({ + (quirks.smartthings.SmartthingsTemperatureHumiditySensor, 64581): + ('sensor', sensor_zha.RelativeHumiditySensor) + }) + # A map of hass components to all Zigbee clusters it could use for profile_id, classes in DEVICE_CLASS.items(): profile = PROFILES[profile_id] diff --git a/homeassistant/components/zoneminder.py b/homeassistant/components/zoneminder/__init__.py similarity index 78% rename from homeassistant/components/zoneminder.py rename to homeassistant/components/zoneminder/__init__.py index 3f6d8ba7fcf..e5d0c7a5a92 100644 --- a/homeassistant/components/zoneminder.py +++ b/homeassistant/components/zoneminder/__init__.py @@ -10,12 +10,12 @@ import voluptuous as vol from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PATH, CONF_SSL, CONF_USERNAME, - CONF_VERIFY_SSL) + CONF_VERIFY_SSL, ATTR_NAME) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['zm-py==0.0.5'] +REQUIREMENTS = ['zm-py==0.1.0'] CONF_PATH_ZMS = 'path_zms' @@ -38,6 +38,11 @@ CONFIG_SCHEMA = vol.Schema({ }) }, extra=vol.ALLOW_EXTRA) +SERVICE_SET_RUN_STATE = 'set_run_state' +SET_RUN_STATE_SCHEMA = vol.Schema({ + vol.Required(ATTR_NAME): cv.string +}) + def setup(hass, config): """Set up the ZoneMinder component.""" @@ -57,4 +62,13 @@ def setup(hass, config): conf.get(CONF_PATH_ZMS), conf.get(CONF_VERIFY_SSL)) + def set_active_state(call): + """Set the ZoneMinder run state to the given state name.""" + return hass.data[DOMAIN].set_active_state(call.data[ATTR_NAME]) + + hass.services.register( + DOMAIN, SERVICE_SET_RUN_STATE, set_active_state, + schema=SET_RUN_STATE_SCHEMA + ) + return hass.data[DOMAIN].login() diff --git a/homeassistant/components/zoneminder/services.yaml b/homeassistant/components/zoneminder/services.yaml new file mode 100644 index 00000000000..e6346d2f384 --- /dev/null +++ b/homeassistant/components/zoneminder/services.yaml @@ -0,0 +1,6 @@ +set_run_state: + description: Set the ZoneMinder run state + fields: + name: + description: The string name of the ZoneMinder run state to set as active. + example: 'Home' \ No newline at end of file diff --git a/homeassistant/components/zwave/.translations/es.json b/homeassistant/components/zwave/.translations/es.json new file mode 100644 index 00000000000..8c287d9a539 --- /dev/null +++ b/homeassistant/components/zwave/.translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "one_instance_only": "El componente solo admite una instancia de Z-Wave" + }, + "error": { + "option_error": "Z-Wave error de validaci\u00f3n. \u00bfLa ruta de acceso a la memoria USB escorrecta?" + }, + "step": { + "user": { + "data": { + "network_key": "Clave de red (d\u00e9jelo en blanco para generar autom\u00e1ticamente)", + "usb_path": "Ruta USB" + }, + "description": "Consulte https://www.home-assistant.io/docs/z-wave/installation/ para obtener informaci\u00f3n sobre las variables de configuraci\u00f3n", + "title": "Configurar Z-Wave" + } + }, + "title": "Z-Wave" + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave/.translations/fr.json b/homeassistant/components/zwave/.translations/fr.json new file mode 100644 index 00000000000..c667965bebc --- /dev/null +++ b/homeassistant/components/zwave/.translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Z-Wave est d\u00e9j\u00e0 configur\u00e9", + "one_instance_only": "Le composant ne prend en charge qu'une seule instance Z-Wave" + }, + "error": { + "option_error": "La validation Z-Wave a \u00e9chou\u00e9. Le chemin d'acc\u00e8s \u00e0 la cl\u00e9 USB est-il correct?" + }, + "step": { + "user": { + "data": { + "network_key": "Cl\u00e9 r\u00e9seau (laisser vide pour g\u00e9n\u00e9rer automatiquement)", + "usb_path": "Chemin USB" + }, + "title": "Configurer Z-Wave" + } + }, + "title": "Z-Wave" + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave/.translations/hu.json b/homeassistant/components/zwave/.translations/hu.json index 16c25cb7cab..e2acc5f9115 100644 --- a/homeassistant/components/zwave/.translations/hu.json +++ b/homeassistant/components/zwave/.translations/hu.json @@ -1,5 +1,16 @@ { "config": { + "abort": { + "already_configured": "A Z-Wave m\u00e1r konfigur\u00e1lva van" + }, + "step": { + "user": { + "data": { + "network_key": "H\u00e1l\u00f3zati kulcs (hagyja \u00fcresen az automatikus gener\u00e1l\u00e1shoz)", + "usb_path": "USB el\u00e9r\u00e9si \u00fat" + } + } + }, "title": "Z-Wave" } } \ No newline at end of file diff --git a/homeassistant/components/zwave/.translations/ko.json b/homeassistant/components/zwave/.translations/ko.json index d57f758ce25..e288019de0c 100644 --- a/homeassistant/components/zwave/.translations/ko.json +++ b/homeassistant/components/zwave/.translations/ko.json @@ -13,7 +13,7 @@ "network_key": "\ub124\ud2b8\uc6cc\ud06c \ud0a4 (\uacf5\ub780\uc73c\ub85c \ube44\uc6cc\ub450\uba74 \uc790\ub3d9 \uc0dd\uc131\ud569\ub2c8\ub2e4)", "usb_path": "USB \uacbd\ub85c" }, - "description": "\uad6c\uc131 \ubcc0\uc218\uc5d0 \ub300\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4](https://www.home-assistant.io/docs/z-wave/installation/)\ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694", + "description": "\uad6c\uc131 \ubcc0\uc218\uc5d0 \ub300\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4](https://www.home-assistant.io/docs/z-wave/installation/) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694", "title": "Z-Wave \uc124\uc815" } }, diff --git a/homeassistant/components/zwave/.translations/pt.json b/homeassistant/components/zwave/.translations/pt.json new file mode 100644 index 00000000000..6962f077498 --- /dev/null +++ b/homeassistant/components/zwave/.translations/pt.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "O Z-Wave j\u00e1 est\u00e1 configurado", + "one_instance_only": "Componente suporta apenas uma inst\u00e2ncia Z-Wave" + }, + "error": { + "option_error": "A valida\u00e7\u00e3o Z-Wave falhou. O caminho para o stick USB est\u00e1 correto?" + }, + "step": { + "user": { + "data": { + "network_key": "Network Key (deixe em branco para auto-gera\u00e7\u00e3o)", + "usb_path": "Endere\u00e7o USB" + }, + "description": "Consulte https://www.home-assistant.io/docs/z-wave/installation/ para obter informa\u00e7\u00f5es sobre as vari\u00e1veis de configura\u00e7\u00e3o", + "title": "Configurar o Z-Wave" + } + }, + "title": "Z-Wave" + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave/.translations/tr.json b/homeassistant/components/zwave/.translations/tr.json new file mode 100644 index 00000000000..c9762784d52 --- /dev/null +++ b/homeassistant/components/zwave/.translations/tr.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "network_key": "A\u011f Anajtar\u0131 (otomatik \u00fcretilmesi i\u00e7in bo\u015f b\u0131rak\u0131n\u0131z)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index fa78f719557..35703d64974 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -11,6 +11,7 @@ from pprint import pprint import voluptuous as vol +from homeassistant import config_entries from homeassistant.core import callback, CoreState from homeassistant.loader import get_platform from homeassistant.helpers import discovery @@ -28,24 +29,28 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send) from . import const -from .const import DOMAIN, DATA_DEVICES, DATA_NETWORK, DATA_ENTITY_VALUES +from . import config_flow # noqa # pylint: disable=unused-import +from .const import ( + CONF_AUTOHEAL, CONF_DEBUG, CONF_POLLING_INTERVAL, + CONF_USB_STICK_PATH, CONF_CONFIG_PATH, CONF_NETWORK_KEY, + DEFAULT_CONF_AUTOHEAL, DEFAULT_CONF_USB_STICK_PATH, + DEFAULT_POLLING_INTERVAL, DEFAULT_DEBUG, DOMAIN, + DATA_DEVICES, DATA_NETWORK, DATA_ENTITY_VALUES) from .node_entity import ZWaveBaseEntity, ZWaveNodeEntity from . import workaround from .discovery_schemas import DISCOVERY_SCHEMAS from .util import (check_node_schema, check_value_schema, node_name, check_has_unique_id, is_node_parsed) -REQUIREMENTS = ['pydispatcher==2.0.5', 'python_openzwave==0.4.9'] +REQUIREMENTS = ['pydispatcher==2.0.5', 'python_openzwave==0.4.10'] _LOGGER = logging.getLogger(__name__) CLASS_ID = 'class_id' -CONF_AUTOHEAL = 'autoheal' -CONF_DEBUG = 'debug' + +ATTR_POWER = 'power_consumption' + CONF_POLLING_INTENSITY = 'polling_intensity' -CONF_POLLING_INTERVAL = 'polling_interval' -CONF_USB_STICK_PATH = 'usb_path' -CONF_CONFIG_PATH = 'config_path' CONF_IGNORED = 'ignored' CONF_INVERT_OPENCLOSE_BUTTONS = 'invert_openclose_buttons' CONF_REFRESH_VALUE = 'refresh_value' @@ -53,19 +58,17 @@ CONF_REFRESH_DELAY = 'delay' CONF_DEVICE_CONFIG = 'device_config' CONF_DEVICE_CONFIG_GLOB = 'device_config_glob' CONF_DEVICE_CONFIG_DOMAIN = 'device_config_domain' -CONF_NETWORK_KEY = 'network_key' -ATTR_POWER = 'power_consumption' +DATA_ZWAVE_CONFIG = 'zwave_config' -DEFAULT_CONF_AUTOHEAL = True -DEFAULT_CONF_USB_STICK_PATH = '/zwaveusbstick' -DEFAULT_POLLING_INTERVAL = 60000 -DEFAULT_DEBUG = False DEFAULT_CONF_IGNORED = False DEFAULT_CONF_INVERT_OPENCLOSE_BUTTONS = False DEFAULT_CONF_REFRESH_VALUE = False DEFAULT_CONF_REFRESH_DELAY = 5 +SUPPORTED_PLATFORMS = ['binary_sensor', 'climate', 'cover', 'fan', + 'light', 'sensor', 'switch'] + RENAME_NODE_SCHEMA = vol.Schema({ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), vol.Required(const.ATTR_NAME): cv.string, @@ -224,13 +227,32 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info[const.DISCOVERY_DEVICE], None) if device is None: return False - async_add_entities([device]) return True async def async_setup(hass, config): - """Set up Z-Wave. + """Set up Z-Wave components.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + hass.data[DATA_ZWAVE_CONFIG] = conf + + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data={ + CONF_USB_STICK_PATH: conf[CONF_USB_STICK_PATH], + CONF_NETWORK_KEY: conf.get(CONF_NETWORK_KEY), + } + )) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up Z-Wave from a config entry. Will automatically load components to support devices found on the network. """ @@ -240,27 +262,31 @@ async def async_setup(hass, config): from openzwave.network import ZWaveNetwork from openzwave.group import ZWaveGroup + config = {} + if DATA_ZWAVE_CONFIG in hass.data: + config = hass.data[DATA_ZWAVE_CONFIG] + # Load configuration - use_debug = config[DOMAIN].get(CONF_DEBUG) - autoheal = config[DOMAIN].get(CONF_AUTOHEAL) + use_debug = config.get(CONF_DEBUG, DEFAULT_DEBUG) + autoheal = config.get(CONF_AUTOHEAL, + DEFAULT_CONF_AUTOHEAL) device_config = EntityValues( - config[DOMAIN][CONF_DEVICE_CONFIG], - config[DOMAIN][CONF_DEVICE_CONFIG_DOMAIN], - config[DOMAIN][CONF_DEVICE_CONFIG_GLOB]) + config.get(CONF_DEVICE_CONFIG), + config.get(CONF_DEVICE_CONFIG_DOMAIN), + config.get(CONF_DEVICE_CONFIG_GLOB)) # Setup options options = ZWaveOption( - config[DOMAIN].get(CONF_USB_STICK_PATH), + config_entry.data[CONF_USB_STICK_PATH], user_path=hass.config.config_dir, - config_path=config[DOMAIN].get(CONF_CONFIG_PATH)) + config_path=config.get(CONF_CONFIG_PATH)) options.set_console_output(use_debug) - if CONF_NETWORK_KEY in config[DOMAIN]: - options.addOption("NetworkKey", config[DOMAIN][CONF_NETWORK_KEY]) - - options.lock() + if CONF_NETWORK_KEY in config_entry.data: + options.addOption("NetworkKey", config_entry.data[CONF_NETWORK_KEY]) + await hass.async_add_executor_job(options.lock) network = hass.data[DATA_NETWORK] = ZWaveNetwork(options, autostart=False) hass.data[DATA_DEVICES] = {} hass.data[DATA_ENTITY_VALUES] = [] @@ -666,7 +692,7 @@ async def async_setup(hass, config): def _finalize_start(): """Perform final initializations after Z-Wave network is awaked.""" polling_interval = convert( - config[DOMAIN].get(CONF_POLLING_INTERVAL), int) + config.get(CONF_POLLING_INTERVAL), int) if polling_interval is not None: network.set_poll_interval(polling_interval, False) @@ -691,8 +717,6 @@ async def async_setup(hass, config): test_network) hass.services.register(DOMAIN, const.SERVICE_STOP_NETWORK, stop_network) - hass.services.register(DOMAIN, const.SERVICE_START_NETWORK, - start_zwave) hass.services.register(DOMAIN, const.SERVICE_RENAME_NODE, rename_node, schema=RENAME_NODE_SCHEMA) hass.services.register(DOMAIN, const.SERVICE_RENAME_VALUE, @@ -752,6 +776,13 @@ async def async_setup(hass, config): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_zwave) + hass.services.async_register(DOMAIN, const.SERVICE_START_NETWORK, + start_zwave) + + for entry_component in SUPPORTED_PLATFORMS: + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + config_entry, entry_component)) + return True @@ -903,9 +934,13 @@ class ZWaveDeviceEntityValues(): async def discover_device(component, device, dict_id): """Put device in a dictionary and call discovery on it.""" self._hass.data[DATA_DEVICES][dict_id] = device - await discovery.async_load_platform( - self._hass, component, DOMAIN, - {const.DISCOVERY_DEVICE: dict_id}, self._zwave_config) + if component in SUPPORTED_PLATFORMS: + async_dispatcher_send( + self._hass, 'zwave_new_{}'.format(component), device) + else: + await discovery.async_load_platform( + self._hass, component, DOMAIN, + {const.DISCOVERY_DEVICE: dict_id}, self._zwave_config) if device.unique_id: self._hass.add_job(discover_device, component, device, dict_id) @@ -985,6 +1020,18 @@ class ZWaveDeviceEntity(ZWaveBaseEntity): """Return a unique ID.""" return self._unique_id + @property + def device_info(self): + """Return device information.""" + return { + 'identifiers': { + (DOMAIN, self.node_id) + }, + 'manufacturer': self.node.manufacturer_name, + 'model': self.node.product_name, + 'name': node_name(self.node), + } + @property def name(self): """Return the name of the device.""" diff --git a/homeassistant/components/zwave/config_flow.py b/homeassistant/components/zwave/config_flow.py new file mode 100644 index 00000000000..2b853ffa81d --- /dev/null +++ b/homeassistant/components/zwave/config_flow.py @@ -0,0 +1,95 @@ +"""Config flow to configure Z-Wave.""" +from collections import OrderedDict +import logging + +import voluptuous as vol + +from homeassistant import config_entries + +from .const import ( + CONF_USB_STICK_PATH, CONF_NETWORK_KEY, + DEFAULT_CONF_USB_STICK_PATH, DOMAIN) + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register(DOMAIN) +class ZwaveFlowHandler(config_entries.ConfigFlow): + """Handle a Z-Wave config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self): + """Initialize the Z-Wave config flow.""" + self.usb_path = CONF_USB_STICK_PATH + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + if self._async_current_entries(): + return self.async_abort(reason='one_instance_only') + + errors = {} + + fields = OrderedDict() + fields[vol.Required(CONF_USB_STICK_PATH, + default=DEFAULT_CONF_USB_STICK_PATH)] = str + fields[vol.Optional(CONF_NETWORK_KEY)] = str + + if user_input is not None: + # Check if USB path is valid + from openzwave.option import ZWaveOption + from openzwave.object import ZWaveException + + try: + from functools import partial + # pylint: disable=unused-variable + option = await self.hass.async_add_executor_job( # noqa: F841 + partial(ZWaveOption, + user_input[CONF_USB_STICK_PATH], + user_path=self.hass.config.config_dir) + ) + except ZWaveException: + errors['base'] = 'option_error' + return self.async_show_form( + step_id='user', + data_schema=vol.Schema(fields), + errors=errors + ) + + if user_input.get(CONF_NETWORK_KEY) is None: + # Generate a random key + from random import choice + key = str() + for i in range(16): + key += '0x' + key += choice('1234567890ABCDEF') + key += choice('1234567890ABCDEF') + if i < 15: + key += ', ' + user_input[CONF_NETWORK_KEY] = key + + return self.async_create_entry( + title='Z-Wave', + data={ + CONF_USB_STICK_PATH: user_input[CONF_USB_STICK_PATH], + CONF_NETWORK_KEY: user_input[CONF_NETWORK_KEY], + }, + ) + + return self.async_show_form( + step_id='user', data_schema=vol.Schema(fields) + ) + + async def async_step_import(self, info): + """Import existing configuration from Z-Wave.""" + if self._async_current_entries(): + return self.async_abort(reason='already_setup') + + return self.async_create_entry( + title="Z-Wave (import from configuration.yaml)", + data={ + CONF_USB_STICK_PATH: info.get(CONF_USB_STICK_PATH), + CONF_NETWORK_KEY: info.get(CONF_NETWORK_KEY), + }, + ) diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index b84f0287349..fece48655df 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -22,6 +22,18 @@ ATTR_VALUE_INSTANCE = "value_instance" NETWORK_READY_WAIT_SECS = 300 NODE_READY_WAIT_SECS = 30 +CONF_AUTOHEAL = 'autoheal' +CONF_DEBUG = 'debug' +CONF_POLLING_INTERVAL = 'polling_interval' +CONF_USB_STICK_PATH = 'usb_path' +CONF_CONFIG_PATH = 'config_path' +CONF_NETWORK_KEY = 'network_key' + +DEFAULT_CONF_AUTOHEAL = True +DEFAULT_CONF_USB_STICK_PATH = '/zwaveusbstick' +DEFAULT_POLLING_INTERVAL = 60000 +DEFAULT_DEBUG = False + DISCOVERY_DEVICE = 'device' DATA_DEVICES = 'zwave_devices' diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index 94de03686d3..2339b8aba36 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -8,7 +8,7 @@ from homeassistant.helpers.entity import Entity from .const import ( ATTR_NODE_ID, COMMAND_CLASS_WAKE_UP, ATTR_SCENE_ID, ATTR_SCENE_DATA, ATTR_BASIC_LEVEL, EVENT_NODE_EVENT, EVENT_SCENE_ACTIVATED, - COMMAND_CLASS_CENTRAL_SCENE) + COMMAND_CLASS_CENTRAL_SCENE, DOMAIN) from .util import node_name, is_node_parsed _LOGGER = logging.getLogger(__name__) @@ -110,6 +110,18 @@ class ZWaveNodeEntity(ZWaveBaseEntity): """Return unique ID of Z-wave node.""" return self._unique_id + @property + def device_info(self): + """Return device information.""" + return { + 'identifiers': { + (DOMAIN, self.node_id) + }, + 'manufacturer': self.node.manufacturer_name, + 'model': self.node.product_name, + 'name': node_name(self.node) + } + def network_node_changed(self, node=None, value=None, args=None): """Handle a changed node on the network.""" if node and node.node_id != self.node_id: diff --git a/homeassistant/components/zwave/strings.json b/homeassistant/components/zwave/strings.json new file mode 100644 index 00000000000..0ac55e46791 --- /dev/null +++ b/homeassistant/components/zwave/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "title": "Z-Wave", + "step": { + "user": { + "title": "Set up Z-Wave", + "description": "See https://www.home-assistant.io/docs/z-wave/installation/ for information on the configuration variables", + "data": { + "usb_path": "USB Path", + "network_key": "Network Key (leave blank to auto-generate)" + } + } + }, + "error": { + "option_error": "Z-Wave validation failed. Is the path to the USB stick correct?" + }, + "abort": { + "already_configured": "Z-Wave is already configured", + "one_instance_only": "Component only supports one Z-Wave instance" + } + } +} \ No newline at end of file diff --git a/homeassistant/config.py b/homeassistant/config.py index 98857d8a83d..8f0690a02c0 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -105,8 +105,9 @@ map: # Track the sun sun: -# Weather prediction +# Sensors sensor: + # Weather prediction - platform: yr # Text to speech diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 1cc2e1362af..c1c0fbbf775 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -136,19 +136,24 @@ HANDLERS = Registry() # Components that have config flows. In future we will auto-generate this list. FLOWS = [ 'cast', - 'hangouts', 'deconz', + 'hangouts', 'homematicip_cloud', 'hue', 'ifttt', 'ios', + 'lifx', 'mqtt', 'nest', 'openuv', + 'simplisafe', + 'smhi', 'sonos', 'tradfri', - 'zone', + 'unifi', 'upnp', + 'zone', + 'zwave' ] diff --git a/homeassistant/const.py b/homeassistant/const.py index 502a120a33c..5882bd62314 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,8 +1,8 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 80 -PATCH_VERSION = '3' +MINOR_VERSION = 81 +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) @@ -82,6 +82,7 @@ CONF_FRIENDLY_NAME_TEMPLATE = 'friendly_name_template' CONF_HEADERS = 'headers' CONF_HOST = 'host' CONF_HOSTS = 'hosts' +CONF_HS = 'hs' CONF_ICON = 'icon' CONF_ICON_TEMPLATE = 'icon_template' CONF_INCLUDE = 'include' @@ -128,6 +129,7 @@ CONF_SENSOR_TYPE = 'sensor_type' CONF_SENSORS = 'sensors' CONF_SHOW_ON_MAP = 'show_on_map' CONF_SLAVE = 'slave' +CONF_SOURCE = 'source' CONF_SSL = 'ssl' CONF_STATE = 'state' CONF_STATE_TEMPLATE = 'state_template' @@ -145,6 +147,7 @@ CONF_URL = 'url' CONF_USERNAME = 'username' CONF_VALUE_TEMPLATE = 'value_template' CONF_VERIFY_SSL = 'verify_ssl' +CONF_WEBHOOK_ID = 'webhook_id' CONF_WEEKDAY = 'weekday' CONF_WHITELIST = 'whitelist' CONF_WHITELIST_EXTERNAL_DIRS = 'whitelist_external_dirs' @@ -166,6 +169,7 @@ EVENT_SERVICE_REGISTERED = 'service_registered' EVENT_SERVICE_REMOVED = 'service_removed' EVENT_LOGBOOK_ENTRY = 'logbook_entry' EVENT_THEMES_UPDATED = 'themes_updated' +EVENT_TIMER_OUT_OF_SYNC = 'timer_out_of_sync' # #### DEVICE CLASSES #### DEVICE_CLASS_BATTERY = 'battery' @@ -214,6 +218,7 @@ ATTR_CREDENTIALS = 'credentials' ATTR_NOW = 'now' ATTR_DATE = 'date' ATTR_TIME = 'time' +ATTR_SECONDS = 'seconds' # Contains domain, service for a SERVICE_CALL event ATTR_DOMAIN = 'domain' diff --git a/homeassistant/core.py b/homeassistant/core.py index d1f811502e0..1754a8b5014 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -29,11 +29,11 @@ from voluptuous.humanize import humanize_error from homeassistant.const import ( ATTR_DOMAIN, ATTR_FRIENDLY_NAME, ATTR_NOW, ATTR_SERVICE, - ATTR_SERVICE_CALL_ID, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, + ATTR_SERVICE_CALL_ID, ATTR_SERVICE_DATA, ATTR_SECONDS, EVENT_CALL_SERVICE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, + EVENT_HOMEASSISTANT_CLOSE, EVENT_SERVICE_REMOVED, EVENT_SERVICE_EXECUTED, EVENT_SERVICE_REGISTERED, EVENT_STATE_CHANGED, - EVENT_TIME_CHANGED, MATCH_ALL, EVENT_HOMEASSISTANT_CLOSE, - EVENT_SERVICE_REMOVED, __version__) + EVENT_TIME_CHANGED, EVENT_TIMER_OUT_OF_SYNC, MATCH_ALL, __version__) from homeassistant import loader from homeassistant.exceptions import ( HomeAssistantError, InvalidEntityFormatError, InvalidStateError) @@ -1297,8 +1297,11 @@ def _async_create_timer(hass: HomeAssistant) -> None: hass.bus.async_fire(EVENT_TIME_CHANGED, {ATTR_NOW: now}) - if monotonic() > target + 1: - _LOGGER.error('Timer got out of sync. Resetting') + # If we are more than a second late, a tick was missed + late = monotonic() - target + if late > 1: + hass.bus.async_fire(EVENT_TIMER_OUT_OF_SYNC, + {ATTR_SECONDS: late}) schedule_tick(now) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index bb4dcf6a55f..9ce4b6b166d 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -335,9 +335,12 @@ def slugify(value): def string(value: Any) -> str: """Coerce value to string, except for None.""" - if value is not None: - return str(value) - raise vol.Invalid('string value is None') + if value is None: + raise vol.Invalid('string value is None') + if isinstance(value, (list, dict)): + raise vol.Invalid('value should be a string') + + return str(value) def temperature_unit(value) -> str: diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 60fd661a765..687ed0b6f8b 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -346,7 +346,7 @@ class Entity: if hasattr(self, 'async_update'): await self.async_update() elif hasattr(self, 'update'): - await self.hass.async_add_job(self.update) + await self.hass.async_add_executor_job(self.update) finally: self._update_staged = False if warning: @@ -363,14 +363,16 @@ class Entity: async def async_remove(self): """Remove entity from Home Assistant.""" + will_remove = getattr(self, 'async_will_remove_from_hass', None) + + if will_remove: + await will_remove() # pylint: disable=not-callable + if self._on_remove is not None: while self._on_remove: self._on_remove.pop()() - if self.platform is not None: - await self.platform.async_remove_entity(self.entity_id) - else: - self.hass.states.async_remove(self.entity_id) + self.hass.states.async_remove(self.entity_id) @callback def async_registry_updated(self, old, new): diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index c2ab8722c97..982c92510a9 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta from itertools import chain +import logging from homeassistant import config as conf_util from homeassistant.setup import async_prepare_setup_platform @@ -11,10 +12,33 @@ from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform, discovery from homeassistant.helpers.service import extract_entity_ids +from homeassistant.loader import bind_hass from homeassistant.util import slugify from .entity_platform import EntityPlatform DEFAULT_SCAN_INTERVAL = timedelta(seconds=15) +DATA_INSTANCES = 'entity_components' + + +@bind_hass +async def async_update_entity(hass, entity_id): + """Trigger an update for an entity.""" + domain = entity_id.split('.', 1)[0] + entity_comp = hass.data.get(DATA_INSTANCES, {}).get(domain) + + if entity_comp is None: + logging.getLogger(__name__).warning( + 'Forced update failed. Component for %s not loaded.', entity_id) + return + + entity = entity_comp.get_entity(entity_id) + + if entity is None: + logging.getLogger(__name__).warning( + 'Forced update failed. Entity %s not found.', entity_id) + return + + await entity.async_update_ha_state(True) class EntityComponent: @@ -45,6 +69,8 @@ class EntityComponent: self.async_add_entities = self._platforms[domain].async_add_entities self.add_entities = self._platforms[domain].add_entities + hass.data.setdefault(DATA_INSTANCES, {})[domain] = self + @property def entities(self): """Return an iterable that returns all entities.""" diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 3ab45577236..5fd580a33f0 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -345,8 +345,10 @@ class EntityPlatform: raise HomeAssistantError( msg) - self.entities[entity.entity_id] = entity - component_entities.add(entity.entity_id) + entity_id = entity.entity_id + self.entities[entity_id] = entity + component_entities.add(entity_id) + entity.async_on_remove(lambda: self.entities.pop(entity_id)) if hasattr(entity, 'async_added_to_hass'): await entity.async_added_to_hass() @@ -365,7 +367,7 @@ class EntityPlatform: if not self.entities: return - tasks = [self._async_remove_entity(entity_id) + tasks = [self.async_remove_entity(entity_id) for entity_id in self.entities] await asyncio.wait(tasks, loop=self.hass.loop) @@ -376,7 +378,7 @@ class EntityPlatform: async def async_remove_entity(self, entity_id): """Remove entity id from platform.""" - await self._async_remove_entity(entity_id) + await self.entities[entity_id].async_remove() # Clean up polling job if no longer needed if (self._async_unsub_polling is not None and @@ -385,15 +387,6 @@ class EntityPlatform: self._async_unsub_polling() self._async_unsub_polling = None - async def _async_remove_entity(self, entity_id): - """Remove entity id from platform.""" - entity = self.entities.pop(entity_id) - - if hasattr(entity, 'async_will_remove_from_hass'): - await entity.async_will_remove_from_hass() - - self.hass.states.async_remove(entity_id) - async def _update_entity_states(self, now): """Update the states of all the polling entities. diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 4a5daa182fa..5adf748dc58 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -185,7 +185,7 @@ class EntityRegistry: for listener_ref in new.update_listeners: listener = listener_ref() if listener is None: - to_remove.append(listener) + to_remove.append(listener_ref) else: try: listener.async_registry_updated(old, new) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 05555e8b5c6..1c28e2878e9 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -322,13 +322,13 @@ track_sunset = threaded_listener_factory(async_track_sunset) @callback @bind_hass -def async_track_utc_time_change(hass, action, year=None, month=None, day=None, +def async_track_utc_time_change(hass, action, hour=None, minute=None, second=None, local=False): """Add a listener that will fire if time matches a pattern.""" # We do not have to wrap the function with time pattern matching logic # if no pattern given - if all(val is None for val in (year, month, day, hour, minute, second)): + if all(val is None for val in (hour, minute, second)): @callback def time_change_listener(event): """Fire every time event that comes in.""" @@ -336,24 +336,45 @@ def async_track_utc_time_change(hass, action, year=None, month=None, day=None, return hass.bus.async_listen(EVENT_TIME_CHANGED, time_change_listener) - pmp = _process_time_match - year, month, day = pmp(year), pmp(month), pmp(day) - hour, minute, second = pmp(hour), pmp(minute), pmp(second) + matching_seconds = dt_util.parse_time_expression(second, 0, 59) + matching_minutes = dt_util.parse_time_expression(minute, 0, 59) + matching_hours = dt_util.parse_time_expression(hour, 0, 23) + + next_time = None + + def calculate_next(now): + """Calculate and set the next time the trigger should fire.""" + nonlocal next_time + + localized_now = dt_util.as_local(now) if local else now + next_time = dt_util.find_next_time_expression_time( + localized_now, matching_seconds, matching_minutes, + matching_hours) + + # Make sure rolling back the clock doesn't prevent the timer from + # triggering. + last_now = None @callback def pattern_time_change_listener(event): """Listen for matching time_changed events.""" + nonlocal next_time, last_now + now = event.data[ATTR_NOW] - if local: - now = dt_util.as_local(now) + if last_now is None or now < last_now: + # Time rolled back or next time not yet calculated + calculate_next(now) - # pylint: disable=too-many-boolean-expressions - if second(now.second) and minute(now.minute) and hour(now.hour) and \ - day(now.day) and month(now.month) and year(now.year): + last_now = now - hass.async_run_job(action, now) + if next_time <= now: + hass.async_run_job(action, event.data[ATTR_NOW]) + calculate_next(now + timedelta(seconds=1)) + # We can't use async_track_point_in_utc_time here because it would + # break in the case that the system time abruptly jumps backwards. + # Our custom last_now logic takes care of resolving that scenario. return hass.bus.async_listen(EVENT_TIME_CHANGED, pattern_time_change_listener) @@ -363,11 +384,10 @@ track_utc_time_change = threaded_listener_factory(async_track_utc_time_change) @callback @bind_hass -def async_track_time_change(hass, action, year=None, month=None, day=None, - hour=None, minute=None, second=None): +def async_track_time_change(hass, action, hour=None, minute=None, second=None): """Add a listener that will fire if UTC time matches a pattern.""" - return async_track_utc_time_change(hass, action, year, month, day, hour, - minute, second, local=True) + return async_track_utc_time_change(hass, action, hour, minute, second, + local=True) track_time_change = threaded_listener_factory(async_track_time_change) @@ -383,19 +403,3 @@ def _process_state_match(parameter): parameter = tuple(parameter) return lambda state: state in parameter - - -def _process_time_match(parameter): - """Wrap parameter in a tuple if it is not one and returns it.""" - if parameter is None or parameter == MATCH_ALL: - return lambda _: True - - if isinstance(parameter, str) and parameter.startswith('/'): - parameter = float(parameter[1:]) - return lambda time: time % parameter == 0 - - if isinstance(parameter, str) or not hasattr(parameter, '__iter__'): - return lambda time: time == parameter - - parameter = tuple(parameter) - return lambda time: time in parameter diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index c68aa311998..4650a4d92c2 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -367,18 +367,9 @@ class TemplateMethods: while to_process: value = to_process.pop(0) + point_state = self._resolve_state(value) - if isinstance(value, State): - latitude = value.attributes.get(ATTR_LATITUDE) - longitude = value.attributes.get(ATTR_LONGITUDE) - - if latitude is None or longitude is None: - _LOGGER.warning( - "Distance:State does not contains a location: %s", - value) - return None - - else: + if point_state is None: # We expect this and next value to be lat&lng if not to_process: _LOGGER.warning( @@ -395,6 +386,22 @@ class TemplateMethods: "longitude: %s, %s", value, value_2) return None + else: + if not loc_helper.has_location(point_state): + _LOGGER.warning( + "distance:State does not contain valid location: %s", + point_state) + return None + + latitude = point_state.attributes.get(ATTR_LATITUDE) + longitude = point_state.attributes.get(ATTR_LONGITUDE) + + if latitude is None or longitude is None: + _LOGGER.warning( + "Distance:State does not contains a location: %s", + value) + return None + locations.append((latitude, longitude)) if len(locations) == 1: diff --git a/homeassistant/monkey_patch.py b/homeassistant/monkey_patch.py index edd25817f5a..52efa586c7f 100644 --- a/homeassistant/monkey_patch.py +++ b/homeassistant/monkey_patch.py @@ -25,7 +25,7 @@ from typing import Any def patch_weakref_tasks() -> None: """Replace weakref.WeakSet to address Python 3 bug.""" - # pylint: disable=no-self-use, protected-access, bare-except + # pylint: disable=no-self-use, protected-access import asyncio.tasks class IgnoreCalls: @@ -38,7 +38,7 @@ def patch_weakref_tasks() -> None: asyncio.tasks.Task._all_tasks = IgnoreCalls() # type: ignore try: del asyncio.tasks.Task.__del__ - except: # noqa: E722 + except: # noqa: E722 pylint: disable=bare-except pass diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9217e3b3961..fa0d675f4b1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,6 +1,6 @@ aiohttp==3.4.4 astral==1.6.1 -async_timeout==3.0.0 +async_timeout==3.0.1 attrs==18.2.0 bcrypt==3.1.4 certifi>=2018.04.16 diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index 04456b8cb2f..0185128abac 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -145,8 +145,7 @@ def run_coroutine_threadsafe( """Handle the call to the coroutine.""" try: _chain_future(ensure_future(coro, loop=loop), future) - # pylint: disable=broad-except - except Exception as exc: + except Exception as exc: # pylint: disable=broad-except if future.set_running_or_notify_cancel(): future.set_exception(exc) else: @@ -194,8 +193,7 @@ def run_callback_threadsafe(loop: AbstractEventLoop, callback: Callable, """Run callback and store result.""" try: future.set_result(callback(*args)) - # pylint: disable=broad-except - except Exception as exc: + except Exception as exc: # pylint: disable=broad-except if future.set_running_or_notify_cancel(): future.set_exception(exc) else: diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 5d4b10454a7..b3f7cdd434c 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -1,10 +1,14 @@ """Helper methods to handle the time in Home Assistant.""" import datetime as dt import re -from typing import Any, Dict, Union, Optional, Tuple # noqa pylint: disable=unused-import +from typing import (Any, Union, Optional, # noqa pylint: disable=unused-import + Tuple, List, cast, Dict) import pytz import pytz.exceptions as pytzexceptions +import pytz.tzinfo as pytzinfo # noqa pylint: disable=unused-import + +from homeassistant.const import MATCH_ALL DATE_STR_FORMAT = "%Y-%m-%d" UTC = pytz.utc @@ -209,3 +213,162 @@ def get_age(date: dt.datetime) -> str: return formatn(minute, 'minute') return formatn(second, 'second') + + +def parse_time_expression(parameter: Any, min_value: int, max_value: int) \ + -> List[int]: + """Parse the time expression part and return a list of times to match.""" + if parameter is None or parameter == MATCH_ALL: + res = [x for x in range(min_value, max_value + 1)] + elif isinstance(parameter, str) and parameter.startswith('/'): + parameter = float(parameter[1:]) + res = [x for x in range(min_value, max_value + 1) + if x % parameter == 0] + elif not hasattr(parameter, '__iter__'): + res = [int(parameter)] + else: + res = list(sorted(int(x) for x in parameter)) + + for val in res: + if val < min_value or val > max_value: + raise ValueError( + "Time expression '{}': parameter {} out of range ({} to {})" + "".format(parameter, val, min_value, max_value) + ) + + return res + + +# pylint: disable=redefined-outer-name +def find_next_time_expression_time(now: dt.datetime, + seconds: List[int], minutes: List[int], + hours: List[int]) -> dt.datetime: + """Find the next datetime from now for which the time expression matches. + + The algorithm looks at each time unit separately and tries to find the + next one that matches for each. If any of them would roll over, all + time units below that are reset to the first matching value. + + Timezones are also handled (the tzinfo of the now object is used), + including daylight saving time. + """ + if not seconds or not minutes or not hours: + raise ValueError("Cannot find a next time: Time expression never " + "matches!") + + def _lower_bound(arr: List[int], cmp: int) -> Optional[int]: + """Return the first value in arr greater or equal to cmp. + + Return None if no such value exists. + """ + left = 0 + right = len(arr) + while left < right: + mid = (left + right) // 2 + if arr[mid] < cmp: + left = mid + 1 + else: + right = mid + + if left == len(arr): + return None + return arr[left] + + result = now.replace(microsecond=0) + + # Match next second + next_second = _lower_bound(seconds, result.second) + if next_second is None: + # No second to match in this minute. Roll-over to next minute. + next_second = seconds[0] + result += dt.timedelta(minutes=1) + + result = result.replace(second=next_second) + + # Match next minute + next_minute = _lower_bound(minutes, result.minute) + if next_minute != result.minute: + # We're in the next minute. Seconds needs to be reset. + result = result.replace(second=seconds[0]) + + if next_minute is None: + # No minute to match in this hour. Roll-over to next hour. + next_minute = minutes[0] + result += dt.timedelta(hours=1) + + result = result.replace(minute=next_minute) + + # Match next hour + next_hour = _lower_bound(hours, result.hour) + if next_hour != result.hour: + # We're in the next hour. Seconds+minutes needs to be reset. + result.replace(second=seconds[0], minute=minutes[0]) + + if next_hour is None: + # No minute to match in this day. Roll-over to next day. + next_hour = hours[0] + result += dt.timedelta(days=1) + + result = result.replace(hour=next_hour) + + if result.tzinfo is None: + return result + + # Now we need to handle timezones. We will make this datetime object + # "naive" first and then re-convert it to the target timezone. + # This is so that we can call pytz's localize and handle DST changes. + tzinfo = result.tzinfo # type: pytzinfo.DstTzInfo + result = result.replace(tzinfo=None) + + try: + result = tzinfo.localize(result, is_dst=None) + except pytzexceptions.AmbiguousTimeError: + # This happens when we're leaving daylight saving time and local + # clocks are rolled back. In this case, we want to trigger + # on both the DST and non-DST time. So when "now" is in the DST + # use the DST-on time, and if not, use the DST-off time. + use_dst = bool(now.dst()) + result = tzinfo.localize(result, is_dst=use_dst) + except pytzexceptions.NonExistentTimeError: + # This happens when we're entering daylight saving time and local + # clocks are rolled forward, thus there are local times that do + # not exist. In this case, we want to trigger on the next time + # that *does* exist. + # In the worst case, this will run through all the seconds in the + # time shift, but that's max 3600 operations for once per year + result = result.replace(tzinfo=tzinfo) + dt.timedelta(seconds=1) + return find_next_time_expression_time(result, seconds, minutes, hours) + + result_dst = cast(dt.timedelta, result.dst()) + now_dst = cast(dt.timedelta, now.dst()) + if result_dst >= now_dst: + return result + + # Another edge-case when leaving DST: + # When now is in DST and ambiguous *and* the next trigger time we *should* + # trigger is ambiguous and outside DST, the excepts above won't catch it. + # For example: if triggering on 2:30 and now is 28.10.2018 2:30 (in DST) + # we should trigger next on 28.10.2018 2:30 (out of DST), but our + # algorithm above would produce 29.10.2018 2:30 (out of DST) + + # Step 1: Check if now is ambiguous + try: + tzinfo.localize(now.replace(tzinfo=None), is_dst=None) + return result + except pytzexceptions.AmbiguousTimeError: + pass + + # Step 2: Check if result of (now - DST) is ambiguous. + check = now - now_dst + check_result = find_next_time_expression_time( + check, seconds, minutes, hours) + try: + tzinfo.localize(check_result.replace(tzinfo=None), is_dst=None) + return result + except pytzexceptions.AmbiguousTimeError: + pass + + # OK, edge case does apply. We must override the DST to DST-off + check_result = tzinfo.localize(check_result.replace(tzinfo=None), + is_dst=False) + return check_result diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 0a2a2a1edf3..b002c8e3147 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -4,7 +4,7 @@ from typing import Union, List, Dict import json import os -from os import O_WRONLY, O_CREAT, O_TRUNC +import tempfile from homeassistant.exceptions import HomeAssistantError @@ -46,13 +46,17 @@ def save_json(filename: str, data: Union[List, Dict], Returns True on success. """ - tmp_filename = filename + "__TEMP__" + tmp_filename = "" + tmp_path = os.path.split(filename)[0] try: json_data = json.dumps(data, sort_keys=True, indent=4) - mode = 0o600 if private else 0o644 - with open(os.open(tmp_filename, O_WRONLY | O_CREAT | O_TRUNC, mode), - 'w', encoding='utf-8') as fdesc: + # Modern versions of Python tempfile create this file with mode 0o600 + with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8', + dir=tmp_path, delete=False) as fdesc: fdesc.write(json_data) + tmp_filename = fdesc.name + if not private: + os.chmod(tmp_filename, 0o644) os.replace(tmp_filename, filename) except TypeError as error: _LOGGER.exception('Failed to serialize to JSON: %s', diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 5a8f515c3ad..5f6d202b5e9 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -13,6 +13,7 @@ from homeassistant.const import ( TEMPERATURE, UNIT_NOT_RECOGNIZED_TEMPLATE) from homeassistant.util import temperature as temperature_util from homeassistant.util import distance as distance_util +from homeassistant.util import volume as volume_util _LOGGER = logging.getLogger(__name__) @@ -108,6 +109,13 @@ class UnitSystem: return distance_util.convert(length, from_unit, self.length_unit) + def volume(self, volume: Optional[float], from_unit: str) -> float: + """Convert the given volume to this unit system.""" + if not isinstance(volume, Number): + raise TypeError('{} is not a numeric value.'.format(str(volume))) + + return volume_util.convert(volume, from_unit, self.volume_unit) + def as_dict(self) -> dict: """Convert the unit system to a dictionary.""" return { diff --git a/homeassistant/util/volume.py b/homeassistant/util/volume.py new file mode 100644 index 00000000000..154fb3d2c8b --- /dev/null +++ b/homeassistant/util/volume.py @@ -0,0 +1,45 @@ +"""Volume conversion util functions.""" + +import logging +from numbers import Number +from homeassistant.const import (VOLUME_LITERS, VOLUME_MILLILITERS, + VOLUME_GALLONS, VOLUME_FLUID_OUNCE, + VOLUME, UNIT_NOT_RECOGNIZED_TEMPLATE) + +_LOGGER = logging.getLogger(__name__) + +VALID_UNITS = [VOLUME_LITERS, VOLUME_MILLILITERS, VOLUME_GALLONS, + VOLUME_FLUID_OUNCE] + + +def __liter_to_gallon(liter: float) -> float: + """Convert a volume measurement in Liter to Gallon.""" + return liter * .2642 + + +def __gallon_to_liter(gallon: float) -> float: + """Convert a volume measurement in Gallon to Liter.""" + return gallon * 3.785 + + +def convert(volume: float, from_unit: str, to_unit: str) -> float: + """Convert a temperature from one unit to another.""" + if from_unit not in VALID_UNITS: + raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(from_unit, + VOLUME)) + if to_unit not in VALID_UNITS: + raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, VOLUME)) + + if not isinstance(volume, Number): + raise TypeError('{} is not of numeric type'.format(volume)) + + if from_unit == to_unit: + return volume + + result = volume + if from_unit == VOLUME_LITERS and to_unit == VOLUME_GALLONS: + result = __liter_to_gallon(volume) + elif from_unit == VOLUME_GALLONS and to_unit == VOLUME_LITERS: + result = __gallon_to_liter(volume) + + return result diff --git a/requirements_all.txt b/requirements_all.txt index a088fb696d3..ecb09679d42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1,7 +1,7 @@ # Home Assistant core aiohttp==3.4.4 astral==1.6.1 -async_timeout==3.0.0 +async_timeout==3.0.1 attrs==18.2.0 bcrypt==3.1.4 certifi>=2018.04.16 @@ -34,7 +34,7 @@ Adafruit-SHT31==1.0.2 DoorBirdPy==0.1.3 # homeassistant.components.homekit -HAP-python==2.2.2 +HAP-python==2.3.0 # homeassistant.components.notify.mastodon Mastodon.py==1.3.1 @@ -52,13 +52,16 @@ PyMata==2.14 PyQRCode==1.2.1 # homeassistant.components.sensor.rmvtransport -PyRMVtransport==0.1 +PyRMVtransport==0.1.3 # homeassistant.components.switch.switchbot PySwitchbot==0.3 +# homeassistant.components.sensor.transport_nsw +PyTransportNSW==0.0.8 + # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.11.0 +PyXiaomiGateway==0.11.1 # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.1 @@ -79,7 +82,7 @@ WazeRouteCalculator==0.6 YesssSMS==0.2.3 # homeassistant.components.abode -abodepy==0.13.1 +abodepy==0.14.0 # homeassistant.components.media_player.frontier_silicon afsapi==0.0.4 @@ -106,8 +109,8 @@ aiohue==1.5.0 # homeassistant.components.sensor.imap aioimaplib==0.7.13 -# homeassistant.components.light.lifx -aiolifx==0.6.3 +# homeassistant.components.lifx +aiolifx==0.6.5 # homeassistant.components.light.lifx aiolifx_effects==0.2.1 @@ -115,6 +118,9 @@ aiolifx_effects==0.2.1 # homeassistant.components.scene.hunterdouglas_powerview aiopvapi==1.5.4 +# homeassistant.components.unifi +aiounifi==3 + # homeassistant.components.cover.aladdin_connect aladdin_connect==0.3 @@ -147,7 +153,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.upnp # homeassistant.components.media_player.dlna_dmr -async-upnp-client==0.12.6 +async-upnp-client==0.12.7 # homeassistant.components.light.avion # avion==0.7 @@ -179,7 +185,7 @@ bellows==0.7.0 bimmer_connected==0.5.3 # homeassistant.components.blink -blinkpy==0.10.0 +blinkpy==0.10.1 # homeassistant.components.light.blinksticklight blinkstick==1.1.8 @@ -196,11 +202,12 @@ blockchain==1.4.4 # homeassistant.components.sensor.bme680 # bme680==1.0.4 +# homeassistant.components.route53 # homeassistant.components.notify.aws_lambda # homeassistant.components.notify.aws_sns # homeassistant.components.notify.aws_sqs # homeassistant.components.tts.amazon_polly -boto3==1.4.7 +boto3==1.9.16 # homeassistant.scripts.credstash botocore==1.7.34 @@ -253,7 +260,7 @@ concord232==0.15 # homeassistant.components.sensor.xiaomi_miio # homeassistant.components.switch.xiaomi_miio # homeassistant.components.vacuum.xiaomi_miio -construct==2.9.41 +construct==2.9.45 # homeassistant.scripts.credstash # credstash==1.14.0 @@ -282,7 +289,7 @@ defusedxml==0.5.0 deluge-client==1.4.0 # homeassistant.components.media_player.denonavr -denonavr==0.7.5 +denonavr==0.7.6 # homeassistant.components.media_player.directv directpy==0.5 @@ -299,14 +306,11 @@ distro==1.3.0 # homeassistant.components.switch.digitalloggers dlipower==0.7.165 -# homeassistant.components.notify.xmpp -dnspython3==1.15.0 - # homeassistant.components.sensor.dovado dovado==0.4.1 # homeassistant.components.sensor.dsmr -dsmr_parser==0.11 +dsmr_parser==0.12 # homeassistant.components.dweet # homeassistant.components.sensor.dweet @@ -321,6 +325,9 @@ einder==0.3.1 # homeassistant.components.sensor.eliqonline eliqonline==1.0.14 +# homeassistant.components.elkm1 +elkm1-lib==0.7.10 + # homeassistant.components.enocean enocean==0.40 @@ -360,7 +367,7 @@ fedexdeliverymanager==1.0.6 feedparser==5.2.1 # homeassistant.components.sensor.fints -fints==0.2.1 +fints==1.0.1 # homeassistant.components.sensor.fitbit fitbit==0.3.0 @@ -369,7 +376,7 @@ fitbit==0.3.0 fixerio==1.0.0a0 # homeassistant.components.light.flux_led -flux_led==0.21 +flux_led==0.22 # homeassistant.components.sensor.foobot foobot_async==0.3.1 @@ -395,6 +402,7 @@ gearbest_parser==1.0.7 geizhals==0.0.7 # homeassistant.components.geo_location.geo_json_events +# homeassistant.components.geo_location.nsw_rural_fire_service_feed geojson_client==0.1 # homeassistant.components.sensor.geo_rss_events @@ -422,7 +430,7 @@ gps3==0.33.3 greenwavereality==0.5.1 # homeassistant.components.media_player.gstreamer -gstreamer-player==1.1.0 +gstreamer-player==1.1.2 # homeassistant.components.ffmpeg ha-ffmpeg==1.9 @@ -440,7 +448,7 @@ hangups==0.4.6 hbmqtt==0.9.4 # homeassistant.components.sensor.jewish_calendar -hdate==0.6.3 +hdate==0.6.5 # homeassistant.components.climate.heatmiser heatmiserV3==0.9.1 @@ -455,10 +463,10 @@ hipnotify==1.0.8 hole==0.3.0 # homeassistant.components.binary_sensor.workday -holidays==0.9.7 +holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181018.0 +home-assistant-frontend==20181026.0 # homeassistant.components.homekit_controller # homekit==0.10 @@ -495,11 +503,14 @@ ihcsdk==2.2.0 influxdb==5.0.0 # homeassistant.components.insteon -insteonplm==0.14.2 +insteonplm==0.15.0 # homeassistant.components.sensor.iperf3 iperf3==0.1.10 +# homeassistant.components.route53 +ipify==1.0.0 + # homeassistant.components.verisure jsonpath==0.75 @@ -520,7 +531,7 @@ keyrings.alt==3.1 kiwiki-client==0.1.1 # homeassistant.components.konnected -konnected==0.1.2 +konnected==0.1.4 # homeassistant.components.eufy lakeside==0.10 @@ -548,7 +559,7 @@ liffylights==0.9.4 lightify==1.0.6.1 # homeassistant.components.light.limitlessled -limitlessled==1.1.2 +limitlessled==1.1.3 # homeassistant.components.linode linode-api==4.1.9b1 @@ -561,7 +572,7 @@ liveboxplaytv==2.0.2 lmnotify==0.0.4 # homeassistant.components.device_tracker.google_maps -locationsharinglib==3.0.3 +locationsharinglib==3.0.6 # homeassistant.components.logi_circle logi_circle==0.1.7 @@ -594,6 +605,9 @@ mficlient==0.3.0 # homeassistant.components.sensor.miflora miflora==0.4.0 +# homeassistant.components.climate.mill +millheater==0.1.2 + # homeassistant.components.sensor.mitemp_bt mitemp_bt==0.0.1 @@ -626,7 +640,7 @@ ndms2_client==0.0.4 netdata==0.1.2 # homeassistant.components.discovery -netdisco==2.1.0 +netdisco==2.2.0 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 @@ -642,7 +656,7 @@ nuheat==0.3.0 # homeassistant.components.binary_sensor.trend # homeassistant.components.image_processing.opencv -numpy==1.15.1 +numpy==1.15.2 # homeassistant.components.google oauth2client==4.0.0 @@ -759,13 +773,13 @@ pyCEC==0.4.13 pyHS100==0.3.3 # homeassistant.components.weather.met -pyMetno==0.2.0 +pyMetno==0.3.0 # homeassistant.components.rfxtrx pyRFXtrx==0.23 # homeassistant.components.switch.switchmate -pySwitchmate==0.4.1 +pySwitchmate==0.4.2 # homeassistant.components.tibber pyTibber==0.7.2 @@ -776,9 +790,6 @@ pyW215==0.6.0 # homeassistant.components.sensor.noaa_tides # py_noaa==0.3.0 -# homeassistant.components.cover.ryobi_gdo -py_ryobi_gdo==0.0.10 - # homeassistant.components.ads pyads==2.2.6 @@ -789,13 +800,7 @@ pyairvisual==2.0.1 pyalarmdotcom==0.3.2 # homeassistant.components.arlo -pyarlo==0.2.0 - -# homeassistant.components.notify.xmpp -pyasn1-modules==0.1.5 - -# homeassistant.components.notify.xmpp -pyasn1==0.3.7 +pyarlo==0.2.2 # homeassistant.components.netatmo pyatmo==1.2 @@ -856,7 +861,7 @@ pydukeenergy==0.0.6 # homeassistant.components.sensor.ebox pyebox==1.1.4 -# homeassistant.components.climate.econet +# homeassistant.components.water_heater.econet pyeconet==0.0.6 # homeassistant.components.switch.edimax @@ -912,7 +917,7 @@ pyhik==0.1.8 pyhiveapi==0.2.14 # homeassistant.components.homematic -pyhomematic==0.1.50 +pyhomematic==0.1.51 # homeassistant.components.sensor.hydroquebec pyhydroquebec==2.2.2 @@ -952,10 +957,10 @@ pylgnetcast-homeassistant==0.2.0.dev0 # homeassistant.components.media_player.webostv # homeassistant.components.notify.webostv -pylgtv==0.1.7 +pylgtv==0.1.9 # homeassistant.components.sensor.linky -pylinky==0.1.6 +pylinky==0.1.8 # homeassistant.components.litejet pylitejet==0.1 @@ -1000,10 +1005,10 @@ pymysensors==0.17.0 pynello==1.5.1 # homeassistant.components.device_tracker.netgear -pynetgear==0.4.2 +pynetgear==0.5.0 # homeassistant.components.switch.netio -pynetio==0.1.6 +pynetio==0.1.9.1 # homeassistant.components.lock.nuki pynuki==1.3.1 @@ -1024,8 +1029,8 @@ pyoppleio==1.0.5 # homeassistant.components.iota pyota==2.0.5 -# homeassistant.components.climate.opentherm_gw -pyotgw==0.1b0 +# homeassistant.components.opentherm_gw +pyotgw==0.2b1 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp @@ -1048,8 +1053,11 @@ pyqwikswitch==0.8 # homeassistant.components.rainbird pyrainbird==0.1.6 +# homeassistant.components.switch.recswitch +pyrecswitch==1.0.2 + # homeassistant.components.sabnzbd -pysabnzbd==1.0.1 +pysabnzbd==1.1.0 # homeassistant.components.climate.sensibo pysensibo==1.0.3 @@ -1064,7 +1072,7 @@ pyserial==3.1.1 pysesame==0.1.0 # homeassistant.components.goalfeed -pysher==0.2.0 +pysher==1.0.4 # homeassistant.components.sensor.sma pysma==0.2 @@ -1144,7 +1152,7 @@ python-juicenet==0.0.5 # homeassistant.components.sensor.xiaomi_miio # homeassistant.components.switch.xiaomi_miio # homeassistant.components.vacuum.xiaomi_miio -python-miio==0.4.1 +python-miio==0.4.2 # homeassistant.components.media_player.mpd python-mpd2==1.0.0 @@ -1181,7 +1189,7 @@ python-synology==0.2.0 python-tado==0.2.3 # homeassistant.components.telegram_bot -python-telegram-bot==11.0.0 +python-telegram-bot==11.1.0 # homeassistant.components.sensor.twitch python-twitch-client==0.6.0 @@ -1199,7 +1207,7 @@ python-wink==1.10.1 python_opendata_transport==0.1.4 # homeassistant.components.zwave -python_openzwave==0.4.9 +python_openzwave==0.4.10 # homeassistant.components.egardia pythonegardia==1.0.39 @@ -1232,7 +1240,7 @@ pyuptimerobot==0.0.5 # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.44 +pyvera==0.2.45 # homeassistant.components.switch.vesync pyvesync==0.1.1 @@ -1258,6 +1266,9 @@ pyzabbix==0.7.4 # homeassistant.components.sensor.qnap qnapstats==0.2.7 +# homeassistant.components.device_tracker.quantum_gateway +quantum-gateway==0.0.3 + # homeassistant.components.rachio rachiopy==0.1.3 @@ -1294,6 +1305,9 @@ roombapy==1.3.1 # homeassistant.components.switch.rpi_rf # rpi-rf==0.9.6 +# homeassistant.components.lovelace +ruamel.yaml==0.15.72 + # homeassistant.components.media_player.russound_rnet russound==0.1.9 @@ -1329,13 +1343,13 @@ sense_energy==0.4.2 sharp_aquos_rc==0.3.2 # homeassistant.components.sensor.shodan -shodan==1.10.2 +shodan==1.10.4 # homeassistant.components.notify.simplepush simplepush==1.1.4 -# homeassistant.components.alarm_control_panel.simplisafe -simplisafe-python==3.1.2 +# homeassistant.components.simplisafe +simplisafe-python==3.1.12 # homeassistant.components.sisyphus sisyphus-control==2.1 @@ -1346,12 +1360,12 @@ skybellpy==0.1.2 # homeassistant.components.notify.slack slacker==0.9.65 -# homeassistant.components.notify.xmpp -sleekxmpp==1.3.2 - # homeassistant.components.sleepiq sleepyq==0.6 +# homeassistant.components.notify.xmpp +slixmpp==1.4.0 + # homeassistant.components.smappee smappy==0.2.16 @@ -1363,6 +1377,9 @@ smappy==0.2.16 # homeassistant.components.sensor.htu21d # smbus-cffi==0.5.1 +# homeassistant.components.smhi +smhi-pkg==1.0.5 + # homeassistant.components.media_player.snapcast snapcast==2.0.9 @@ -1398,6 +1415,9 @@ statsd==3.2.1 # homeassistant.components.sensor.steam_online steamodd==4.21 +# homeassistant.components.sensor.thermoworks_smoke +stringcase==1.2.0 + # homeassistant.components.ecovacs sucks==0.9.3 @@ -1425,12 +1445,18 @@ tellcore-py==1.1.2 # homeassistant.components.tellduslive tellduslive==0.10.4 +# homeassistant.components.media_player.lg_soundbar +temescal==0.1 + # homeassistant.components.sensor.temper temperusb==1.5.3 # homeassistant.components.tesla teslajsonpy==0.0.23 +# homeassistant.components.sensor.thermoworks_smoke +thermoworks_smoke==0.1.7 + # homeassistant.components.thingspeak thingspeak==0.4.1 @@ -1444,7 +1470,7 @@ todoist-python==7.0.17 toonlib==1.0.2 # homeassistant.components.alarm_control_panel.totalconnect -total_connect_client==0.18 +total_connect_client==0.20 # homeassistant.components.device_tracker.tplink tplink==0.2.1 @@ -1457,7 +1483,7 @@ transmissionrpc==0.11 tuyapy==0.1.3 # homeassistant.components.twilio -twilio==5.7.0 +twilio==6.19.1 # homeassistant.components.sensor.uber uber_rides==0.6.0 @@ -1484,7 +1510,7 @@ volkszaehler==0.1.2 volvooncall==0.4.0 # homeassistant.components.verisure -vsure==1.3.7 +vsure==1.5.0 # homeassistant.components.sensor.vasttrafik vtjp==0.1.14 @@ -1547,13 +1573,13 @@ yahooweather==0.10 yalesmartalarmclient==0.1.4 # homeassistant.components.light.yeelight -yeelight==0.4.0 +yeelight==0.4.3 # homeassistant.components.light.yeelightsunflower yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2018.09.26 +youtube_dl==2018.10.05 # homeassistant.components.light.zengge zengge==0.2 @@ -1574,4 +1600,4 @@ zigpy-xbee==0.1.1 zigpy==0.2.0 # homeassistant.components.zoneminder -zm-py==0.0.5 +zm-py==0.1.0 diff --git a/requirements_test.txt b/requirements_test.txt index 9f36d8f42ca..492708cd904 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,5 +13,5 @@ pytest-aiohttp==0.3.0 pytest-cov==2.5.1 pytest-sugar==0.9.1 pytest-timeout==1.3.2 -pytest==3.8.2 +pytest==3.9.1 requests_mock==1.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b92e5616c3d..7da93c5e0bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -14,15 +14,18 @@ pytest-aiohttp==0.3.0 pytest-cov==2.5.1 pytest-sugar==0.9.1 pytest-timeout==1.3.2 -pytest==3.8.2 +pytest==3.9.1 requests_mock==1.5.2 # homeassistant.components.homekit -HAP-python==2.2.2 +HAP-python==2.3.0 # homeassistant.components.sensor.rmvtransport -PyRMVtransport==0.1 +PyRMVtransport==0.1.3 + +# homeassistant.components.sensor.transport_nsw +PyTransportNSW==0.0.8 # homeassistant.components.notify.yessssms YesssSMS==0.2.3 @@ -37,6 +40,9 @@ aiohttp_cors==0.7.0 # homeassistant.components.hue aiohue==1.5.0 +# homeassistant.components.unifi +aiounifi==3 + # homeassistant.components.notify.apns apns2==0.3.0 @@ -50,7 +56,7 @@ coinmarketcap==5.0.3 defusedxml==0.5.0 # homeassistant.components.sensor.dsmr -dsmr_parser==0.11 +dsmr_parser==0.12 # homeassistant.components.sensor.season ephem==3.7.6.0 @@ -69,6 +75,7 @@ foobot_async==0.3.1 gTTS-token==1.1.2 # homeassistant.components.geo_location.geo_json_events +# homeassistant.components.geo_location.nsw_rural_fire_service_feed geojson_client==0.1 # homeassistant.components.sensor.geo_rss_events @@ -84,13 +91,13 @@ hangups==0.4.6 hbmqtt==0.9.4 # homeassistant.components.sensor.jewish_calendar -hdate==0.6.3 +hdate==0.6.5 # homeassistant.components.binary_sensor.workday -holidays==0.9.7 +holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181018.0 +home-assistant-frontend==20181026.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 @@ -111,7 +118,7 @@ mficlient==0.3.0 # homeassistant.components.binary_sensor.trend # homeassistant.components.image_processing.opencv -numpy==1.15.1 +numpy==1.15.2 # homeassistant.components.mqtt # homeassistant.components.shiftr @@ -150,6 +157,9 @@ pydeconz==47 # homeassistant.components.zwave pydispatcher==2.0.5 +# homeassistant.components.homematic +pyhomematic==0.1.51 + # homeassistant.components.litejet pylitejet==0.1 @@ -205,12 +215,21 @@ rflink==0.0.37 # homeassistant.components.ring ring_doorbell==0.2.1 +# homeassistant.components.lovelace +ruamel.yaml==0.15.72 + # homeassistant.components.media_player.yamaha rxv==0.5.1 +# homeassistant.components.simplisafe +simplisafe-python==3.1.12 + # homeassistant.components.sleepiq sleepyq==0.6 +# homeassistant.components.smhi +smhi-pkg==1.0.5 + # homeassistant.components.climate.honeywell somecomfort==0.5.2 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 9c0323bf5ca..491531ee12b 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -40,6 +40,7 @@ TEST_REQUIREMENTS = ( 'aioautomatic', 'aiohttp_cors', 'aiohue', + 'aiounifi', 'apns2', 'caldav', 'coinmarketcap', @@ -76,6 +77,7 @@ TEST_REQUIREMENTS = ( 'pyblackbird', 'pydeconz', 'pydispatcher', + 'pyhomematic', 'pylitejet', 'pymonoprice', 'pynx584', @@ -84,6 +86,7 @@ TEST_REQUIREMENTS = ( 'pysonos', 'pyqwikswitch', 'PyRMVtransport', + 'PyTransportNSW', 'pyspcwebgw', 'python-forecastio', 'python-nest', @@ -95,7 +98,9 @@ TEST_REQUIREMENTS = ( 'rflink', 'ring_doorbell', 'rxv', + 'simplisafe-python', 'sleepyq', + 'smhi-pkg', 'somecomfort', 'sqlalchemy', 'statsd', @@ -106,6 +111,7 @@ TEST_REQUIREMENTS = ( 'wakeonlan', 'vultr', 'YesssSMS', + 'ruamel.yaml', ) IGNORE_PACKAGES = ( diff --git a/setup.py b/setup.py index 727badb1d94..90f2e8357fd 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ PACKAGES = find_packages(exclude=['tests', 'tests.*']) REQUIRES = [ 'aiohttp==3.4.4', 'astral==1.6.1', - 'async_timeout==3.0.0', + 'async_timeout==3.0.1', 'attrs==18.2.0', 'bcrypt==3.1.4', 'certifi>=2018.04.16', diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py new file mode 100644 index 00000000000..a3bdbab93d7 --- /dev/null +++ b/tests/auth/test_auth_store.py @@ -0,0 +1,82 @@ +"""Tests for the auth store.""" +from homeassistant.auth import auth_store + + +async def test_loading_old_data_format(hass, hass_storage): + """Test we correctly load an old data format.""" + hass_storage[auth_store.STORAGE_KEY] = { + 'version': 1, + 'data': { + 'credentials': [], + 'users': [ + { + "id": "user-id", + "is_active": True, + "is_owner": True, + "name": "Paulus", + "system_generated": False, + }, + { + "id": "system-id", + "is_active": True, + "is_owner": True, + "name": "Hass.io", + "system_generated": True, + } + ], + "refresh_tokens": [ + { + "access_token_expiration": 1800.0, + "client_id": "http://localhost:8123/", + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "user-token-id", + "jwt_key": "some-key", + "last_used_at": "2018-10-03T13:43:19.774712+00:00", + "token": "some-token", + "user_id": "user-id" + }, + { + "access_token_expiration": 1800.0, + "client_id": None, + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "system-token-id", + "jwt_key": "some-key", + "last_used_at": "2018-10-03T13:43:19.774712+00:00", + "token": "some-token", + "user_id": "system-id" + }, + { + "access_token_expiration": 1800.0, + "client_id": "http://localhost:8123/", + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "hidden-because-no-jwt-id", + "last_used_at": "2018-10-03T13:43:19.774712+00:00", + "token": "some-token", + "user_id": "user-id" + }, + ] + } + } + + store = auth_store.AuthStore(hass) + groups = await store.async_get_groups() + assert len(groups) == 1 + group = groups[0] + assert group.name == "All Access" + + users = await store.async_get_users() + assert len(users) == 2 + + owner, system = users + + assert owner.system_generated is False + assert owner.groups == [group] + assert len(owner.refresh_tokens) == 1 + owner_token = list(owner.refresh_tokens.values())[0] + assert owner_token.id == 'user-token-id' + + assert system.system_generated is True + assert system.groups == [] + assert len(system.refresh_tokens) == 1 + system_token = list(system.refresh_tokens.values())[0] + assert system_token.id == 'system-token-id' diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 8fd9b8930e4..4357ba1b1de 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -10,6 +10,7 @@ from homeassistant import auth, data_entry_flow from homeassistant.auth import ( models as auth_models, auth_store, const as auth_const) from homeassistant.auth.const import MFA_SESSION_EXPIRATION +from homeassistant.core import callback from homeassistant.util import dt as dt_util from tests.common import ( MockUser, ensure_auth_manager_loaded, flush_store, CLIENT_ID) @@ -138,6 +139,14 @@ async def test_auth_manager_from_config_auth_modules(mock_hass): async def test_create_new_user(hass): """Test creating new user.""" + events = [] + + @callback + def user_added(event): + events.append(event) + + hass.bus.async_listen('user_added', user_added) + manager = await auth.auth_manager_from_config(hass, [{ 'type': 'insecure_example', 'users': [{ @@ -160,6 +169,10 @@ async def test_create_new_user(hass): assert user.is_owner is False assert user.name == 'Test Name' + await hass.async_block_till_done() + assert len(events) == 1 + assert events[0].data['user_id'] == user.id + async def test_login_as_existing_user(mock_hass): """Test login as existing user.""" @@ -289,6 +302,7 @@ async def test_saving_loading(hass, hass_storage): store2 = auth_store.AuthStore(hass) users = await store2.async_get_users() assert len(users) == 1 + assert users[0].permissions == user.permissions assert users[0] == user assert len(users[0].refresh_tokens) == 2 for r_token in users[0].refresh_tokens.values(): @@ -331,6 +345,14 @@ async def test_cannot_retrieve_expired_access_token(hass): async def test_generating_system_user(hass): """Test that we can add a system user.""" + events = [] + + @callback + def user_added(event): + events.append(event) + + hass.bus.async_listen('user_added', user_added) + manager = await auth.auth_manager_from_config(hass, [], []) user = await manager.async_create_system_user('Hass.io') token = await manager.async_create_refresh_token(user) @@ -338,6 +360,10 @@ async def test_generating_system_user(hass): assert token is not None assert token.client_id is None + await hass.async_block_till_done() + assert len(events) == 1 + assert events[0].data['user_id'] == user.id + async def test_refresh_token_requires_client_for_user(hass): """Test create refresh token for a user with client_id.""" @@ -797,3 +823,50 @@ async def test_enable_mfa_for_user(hass, hass_storage): # disable mfa for user don't enabled just silent fail await manager.async_disable_user_mfa(user, 'insecure_example') + + +async def test_async_remove_user(hass): + """Test removing a user.""" + events = [] + + @callback + def user_removed(event): + events.append(event) + + hass.bus.async_listen('user_removed', user_removed) + + manager = await auth.auth_manager_from_config(hass, [{ + 'type': 'insecure_example', + 'users': [{ + 'username': 'test-user', + 'password': 'test-pass', + 'name': 'Test Name' + }] + }], []) + hass.auth = manager + ensure_auth_manager_loaded(manager) + + # Add fake user with credentials for example auth provider. + user = MockUser( + id='mock-user', + is_owner=False, + is_active=False, + name='Paulus', + ).add_to_auth_manager(manager) + user.credentials.append(auth_models.Credentials( + id='mock-id', + auth_provider_type='insecure_example', + auth_provider_id=None, + data={'username': 'test-user'}, + is_new=False, + )) + assert len(user.credentials) == 1 + + await hass.auth.async_remove_user(user) + + assert len(await manager.async_get_users()) == 0 + assert len(user.credentials) == 0 + + await hass.async_block_till_done() + assert len(events) == 1 + assert events[0].data['user_id'] == user.id diff --git a/tests/auth/test_models.py b/tests/auth/test_models.py new file mode 100644 index 00000000000..c84bdc7390b --- /dev/null +++ b/tests/auth/test_models.py @@ -0,0 +1,34 @@ +"""Tests for the auth models.""" +from homeassistant.auth import models, permissions + + +def test_owner_fetching_owner_permissions(): + """Test we fetch the owner permissions for an owner user.""" + group = models.Group(name="Test Group", policy={}) + owner = models.User(name="Test User", groups=[group], is_owner=True) + assert owner.permissions is permissions.OwnerPermissions + + +def test_permissions_merged(): + """Test we merge the groups permissions.""" + group = models.Group(name="Test Group", policy={ + 'entities': { + 'domains': { + 'switch': True + } + } + }) + group2 = models.Group(name="Test Group", policy={ + 'entities': { + 'entity_ids': { + 'light.kitchen': True + } + } + }) + user = models.User(name="Test User", groups=[group, group2]) + # Make sure we cache instance + assert user.permissions is user.permissions + + assert user.permissions.check_entity('switch.bla') is True + assert user.permissions.check_entity('light.kitchen') is True + assert user.permissions.check_entity('light.not_kitchen') is False diff --git a/tests/auth/test_permissions.py b/tests/auth/test_permissions.py new file mode 100644 index 00000000000..71582dc281d --- /dev/null +++ b/tests/auth/test_permissions.py @@ -0,0 +1,198 @@ +"""Tests for the auth permission system.""" +import pytest +import voluptuous as vol + +from homeassistant.core import State +from homeassistant.auth import permissions + + +def test_entities_none(): + """Test entity ID policy.""" + policy = None + compiled = permissions._compile_entities(policy) + assert compiled('light.kitchen', []) is False + + +def test_entities_empty(): + """Test entity ID policy.""" + policy = {} + permissions.ENTITY_POLICY_SCHEMA(policy) + compiled = permissions._compile_entities(policy) + assert compiled('light.kitchen', []) is False + + +def test_entities_false(): + """Test entity ID policy.""" + policy = False + with pytest.raises(vol.Invalid): + permissions.ENTITY_POLICY_SCHEMA(policy) + + +def test_entities_true(): + """Test entity ID policy.""" + policy = True + permissions.ENTITY_POLICY_SCHEMA(policy) + compiled = permissions._compile_entities(policy) + assert compiled('light.kitchen', []) is True + + +def test_entities_domains_true(): + """Test entity ID policy.""" + policy = { + 'domains': True + } + permissions.ENTITY_POLICY_SCHEMA(policy) + compiled = permissions._compile_entities(policy) + assert compiled('light.kitchen', []) is True + + +def test_entities_domains_domain_true(): + """Test entity ID policy.""" + policy = { + 'domains': { + 'light': True + } + } + permissions.ENTITY_POLICY_SCHEMA(policy) + compiled = permissions._compile_entities(policy) + assert compiled('light.kitchen', []) is True + assert compiled('switch.kitchen', []) is False + + +def test_entities_domains_domain_false(): + """Test entity ID policy.""" + policy = { + 'domains': { + 'light': False + } + } + with pytest.raises(vol.Invalid): + permissions.ENTITY_POLICY_SCHEMA(policy) + + +def test_entities_entity_ids_true(): + """Test entity ID policy.""" + policy = { + 'entity_ids': True + } + permissions.ENTITY_POLICY_SCHEMA(policy) + compiled = permissions._compile_entities(policy) + assert compiled('light.kitchen', []) is True + + +def test_entities_entity_ids_false(): + """Test entity ID policy.""" + policy = { + 'entity_ids': False + } + with pytest.raises(vol.Invalid): + permissions.ENTITY_POLICY_SCHEMA(policy) + + +def test_entities_entity_ids_entity_id_true(): + """Test entity ID policy.""" + policy = { + 'entity_ids': { + 'light.kitchen': True + } + } + permissions.ENTITY_POLICY_SCHEMA(policy) + compiled = permissions._compile_entities(policy) + assert compiled('light.kitchen', []) is True + assert compiled('switch.kitchen', []) is False + + +def test_entities_entity_ids_entity_id_false(): + """Test entity ID policy.""" + policy = { + 'entity_ids': { + 'light.kitchen': False + } + } + with pytest.raises(vol.Invalid): + permissions.ENTITY_POLICY_SCHEMA(policy) + + +def test_policy_perm_filter_states(): + """Test filtering entitites.""" + states = [ + State('light.kitchen', 'on'), + State('light.living_room', 'off'), + State('light.balcony', 'on'), + ] + perm = permissions.PolicyPermissions({ + 'entities': { + 'entity_ids': { + 'light.kitchen': True, + 'light.balcony': True, + } + } + }) + filtered = perm.filter_states(states) + assert len(filtered) == 2 + assert filtered == [states[0], states[2]] + + +def test_owner_permissions(): + """Test owner permissions access all.""" + assert permissions.OwnerPermissions.check_entity('light.kitchen') + states = [ + State('light.kitchen', 'on'), + State('light.living_room', 'off'), + State('light.balcony', 'on'), + ] + assert permissions.OwnerPermissions.filter_states(states) == states + + +def test_default_policy_allow_all(): + """Test that the default policy is to allow all entity actions.""" + perm = permissions.PolicyPermissions(permissions.DEFAULT_POLICY) + assert perm.check_entity('light.kitchen') + states = [ + State('light.kitchen', 'on'), + State('light.living_room', 'off'), + State('light.balcony', 'on'), + ] + assert perm.filter_states(states) == states + + +def test_merging_permissions_true_rules_dict(): + """Test merging policy with two entities.""" + policy1 = { + 'something_else': True, + 'entities': { + 'entity_ids': { + 'light.kitchen': True, + } + } + } + policy2 = { + 'entities': { + 'entity_ids': True + } + } + assert permissions.merge_policies([policy1, policy2]) == { + 'something_else': True, + 'entities': { + 'entity_ids': True + } + } + + +def test_merging_permissions_multiple_subcategories(): + """Test merging policy with two entities.""" + policy1 = { + 'entities': None + } + policy2 = { + 'entities': { + 'entity_ids': True, + } + } + policy3 = { + 'entities': True + } + assert permissions.merge_policies([policy1, policy2]) == policy2 + assert permissions.merge_policies([policy1, policy3]) == policy3 + + assert permissions.merge_policies([policy2, policy3]) == policy3 diff --git a/tests/common.py b/tests/common.py index ee181cfa2e9..44f934e4cb3 100644 --- a/tests/common.py +++ b/tests/common.py @@ -251,7 +251,7 @@ fire_mqtt_message = threadsafe_callback_factory(async_fire_mqtt_message) @ha.callback def async_fire_time_changed(hass, time): """Fire a time changes event.""" - hass.bus.async_fire(EVENT_TIME_CHANGED, {'now': time}) + hass.bus.async_fire(EVENT_TIME_CHANGED, {'now': date_util.as_utc(time)}) fire_time_changed = threadsafe_callback_factory(async_fire_time_changed) @@ -345,17 +345,44 @@ def mock_device_registry(hass, mock_entries=None): return registry +class MockGroup(auth_models.Group): + """Mock a group in Home Assistant.""" + + def __init__(self, id=None, name='Mock Group', + policy=auth_store.DEFAULT_POLICY): + """Mock a group.""" + kwargs = { + 'name': name, + 'policy': policy, + } + if id is not None: + kwargs['id'] = id + + super().__init__(**kwargs) + + def add_to_hass(self, hass): + """Test helper to add entry to hass.""" + return self.add_to_auth_manager(hass.auth) + + def add_to_auth_manager(self, auth_mgr): + """Test helper to add entry to hass.""" + ensure_auth_manager_loaded(auth_mgr) + auth_mgr._store._groups[self.id] = self + return self + + class MockUser(auth_models.User): """Mock a user in Home Assistant.""" def __init__(self, id=None, is_owner=False, is_active=True, - name='Mock User', system_generated=False): + name='Mock User', system_generated=False, groups=None): """Initialize mock user.""" kwargs = { 'is_owner': is_owner, 'is_active': is_active, 'name': name, 'system_generated': system_generated, + 'groups': groups or [], } if id is not None: kwargs['id'] = id diff --git a/tests/components/automation/test_geo_location.py b/tests/components/automation/test_geo_location.py new file mode 100644 index 00000000000..130cdeef99c --- /dev/null +++ b/tests/components/automation/test_geo_location.py @@ -0,0 +1,271 @@ +"""The tests for the geo location trigger.""" +import unittest + +from homeassistant.components import automation, zone +from homeassistant.core import callback, Context +from homeassistant.setup import setup_component + +from tests.common import get_test_home_assistant, mock_component +from tests.components.automation import common + + +class TestAutomationGeoLocation(unittest.TestCase): + """Test the geo location trigger.""" + + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + mock_component(self.hass, 'group') + assert setup_component(self.hass, zone.DOMAIN, { + 'zone': { + 'name': 'test', + 'latitude': 32.880837, + 'longitude': -117.237561, + 'radius': 250, + } + }) + + self.calls = [] + + @callback + def record_call(service): + """Record calls.""" + self.calls.append(service) + + self.hass.services.register('test', 'automation', record_call) + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_if_fires_on_zone_enter(self): + """Test for firing on zone enter.""" + context = Context() + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'enter', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', + 'from_state.state', 'to_state.state', + 'zone.name')) + }, + + } + } + }) + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }, context=context) + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + assert self.calls[0].context is context + self.assertEqual( + 'geo_location - geo_location.entity - hello - hello - test', + self.calls[0].data['some']) + + # Set out of zone again so we can trigger call + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758 + }) + self.hass.block_till_done() + + common.turn_off(self.hass) + self.hass.block_till_done() + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + + def test_if_not_fires_for_enter_on_zone_leave(self): + """Test for not firing on zone leave.""" + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'enter', + }, + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758 + }) + self.hass.block_till_done() + + self.assertEqual(0, len(self.calls)) + + def test_if_fires_on_zone_leave(self): + """Test for firing on zone leave.""" + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'leave', + }, + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758, + 'source': 'test_source' + }) + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + + def test_if_not_fires_for_leave_on_zone_enter(self): + """Test for not firing on zone enter.""" + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'leave', + }, + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.block_till_done() + + self.assertEqual(0, len(self.calls)) + + def test_if_fires_on_zone_appear(self): + """Test for firing if entity appears in zone.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'enter', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', + 'from_state.state', 'to_state.state', + 'zone.name')) + }, + + } + } + }) + + # Entity appears in zone without previously existing outside the zone. + context = Context() + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564, + 'source': 'test_source' + }, context=context) + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + assert self.calls[0].context is context + self.assertEqual( + 'geo_location - geo_location.entity - - hello - test', + self.calls[0].data['some']) + + def test_if_fires_on_zone_disappear(self): + """Test for firing if entity disappears from zone.""" + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'leave', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', + 'from_state.state', 'to_state.state', + 'zone.name')) + }, + + } + } + }) + + # Entity disappears from zone without new coordinates outside the zone. + self.hass.states.async_remove('geo_location.entity') + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + self.assertEqual( + 'geo_location - geo_location.entity - hello - - test', + self.calls[0].data['some']) diff --git a/tests/components/binary_sensor/test_mqtt.py b/tests/components/binary_sensor/test_mqtt.py index 84619ce4ee6..8e9e7dcf301 100644 --- a/tests/components/binary_sensor/test_mqtt.py +++ b/tests/components/binary_sensor/test_mqtt.py @@ -1,5 +1,8 @@ """The tests for the MQTT binary sensor platform.""" +import json import unittest +from unittest.mock import Mock +from datetime import timedelta import homeassistant.core as ha from homeassistant.setup import setup_component, async_setup_component @@ -9,10 +12,12 @@ from homeassistant.components.mqtt.discovery import async_start from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.const import EVENT_STATE_CHANGED, STATE_UNAVAILABLE +import homeassistant.util.dt as dt_util + from tests.common import ( get_test_home_assistant, fire_mqtt_message, async_fire_mqtt_message, - mock_component, mock_mqtt_component, async_mock_mqtt_component, - MockConfigEntry) + fire_time_changed, mock_component, mock_mqtt_component, + async_mock_mqtt_component, MockConfigEntry) class TestSensorMQTT(unittest.TestCase): @@ -21,6 +26,7 @@ class TestSensorMQTT(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """Set up things to be run when tests are started.""" self.hass = get_test_home_assistant() + self.hass.config_entries._async_schedule_save = Mock() mock_mqtt_component(self.hass) def tearDown(self): # pylint: disable=invalid-name @@ -208,6 +214,48 @@ class TestSensorMQTT(unittest.TestCase): self.hass.block_till_done() self.assertEqual(2, len(events)) + def test_off_delay(self): + """Test off_delay option.""" + mock_component(self.hass, 'mqtt') + assert setup_component(self.hass, binary_sensor.DOMAIN, { + binary_sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'payload_on': 'ON', + 'payload_off': 'OFF', + 'off_delay': 30, + 'force_update': True + } + }) + + events = [] + + @ha.callback + def callback(event): + """Verify event got called.""" + events.append(event) + + self.hass.bus.listen(EVENT_STATE_CHANGED, callback) + + fire_mqtt_message(self.hass, 'test-topic', 'ON') + self.hass.block_till_done() + state = self.hass.states.get('binary_sensor.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual(1, len(events)) + + fire_mqtt_message(self.hass, 'test-topic', 'ON') + self.hass.block_till_done() + state = self.hass.states.get('binary_sensor.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual(2, len(events)) + + fire_time_changed(self.hass, dt_util.utcnow() + timedelta(seconds=30)) + self.hass.block_till_done() + state = self.hass.states.get('binary_sensor.test') + self.assertEqual(STATE_OFF, state.state) + self.assertEqual(3, len(events)) + async def test_unique_id(hass): """Test unique id option only creates one sensor per unique_id.""" @@ -250,3 +298,41 @@ async def test_discovery_removal_binary_sensor(hass, mqtt_mock, caplog): await hass.async_block_till_done() state = hass.states.get('binary_sensor.beer') assert state is None + + +async def test_entity_device_info_with_identifier(hass, mqtt_mock): + """Test MQTT binary sensor device registry integration.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + data = json.dumps({ + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'device': { + 'identifiers': ['helloworld'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }, + 'unique_id': 'veryunique' + }) + async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.identifiers == {('mqtt', 'helloworld')} + assert device.connections == {('mac', "02:5b:26:a8:dc:12")} + assert device.manufacturer == 'Whatever' + assert device.name == 'Beer' + assert device.model == 'Glass' + assert device.sw_version == '0.1-beta' diff --git a/tests/components/binary_sensor/test_rflink.py b/tests/components/binary_sensor/test_rflink.py new file mode 100644 index 00000000000..94f4208d5b8 --- /dev/null +++ b/tests/components/binary_sensor/test_rflink.py @@ -0,0 +1,178 @@ +""" +Test for RFlink sensor components. + +Test setup of rflink sensor component/platform. Verify manual and +automatic sensor creation. +""" +from datetime import timedelta +from unittest.mock import patch + +from ..test_rflink import mock_rflink +from homeassistant.components.rflink import ( + CONF_RECONNECT_INTERVAL) + +import homeassistant.core as ha +from homeassistant.const import ( + EVENT_STATE_CHANGED, STATE_ON, STATE_OFF, STATE_UNAVAILABLE) +import homeassistant.util.dt as dt_util + +from tests.common import async_fire_time_changed + +DOMAIN = 'binary_sensor' + +CONFIG = { + 'rflink': { + 'port': '/dev/ttyABC0', + 'ignore_devices': ['ignore_wildcard_*', 'ignore_sensor'], + }, + DOMAIN: { + 'platform': 'rflink', + 'devices': { + 'test': { + 'name': 'test', + 'device_class': 'door', + }, + 'test2': { + 'name': 'test2', + 'device_class': 'motion', + 'off_delay': 30, + 'force_update': True, + }, + }, + }, +} + + +async def test_default_setup(hass, monkeypatch): + """Test all basic functionality of the rflink sensor component.""" + # setup mocking rflink module + event_callback, create, _, disconnect_callback = await mock_rflink( + hass, CONFIG, DOMAIN, monkeypatch) + + # make sure arguments are passed + assert create.call_args_list[0][1]['ignore'] + + # test default state of sensor loaded from config + config_sensor = hass.states.get('binary_sensor.test') + assert config_sensor + assert config_sensor.state == STATE_OFF + assert config_sensor.attributes['device_class'] == 'door' + + # test event for config sensor + event_callback({ + 'id': 'test', + 'command': 'on', + }) + await hass.async_block_till_done() + + assert hass.states.get('binary_sensor.test').state == STATE_ON + + # test event for config sensor + event_callback({ + 'id': 'test', + 'command': 'off', + }) + await hass.async_block_till_done() + + assert hass.states.get('binary_sensor.test').state == STATE_OFF + + +async def test_entity_availability(hass, monkeypatch): + """If Rflink device is disconnected, entities should become unavailable.""" + # Make sure Rflink mock does not 'recover' to quickly from the + # disconnect or else the unavailability cannot be measured + config = CONFIG + failures = [True, True] + config[CONF_RECONNECT_INTERVAL] = 60 + + # Create platform and entities + event_callback, create, _, disconnect_callback = await mock_rflink( + hass, config, DOMAIN, monkeypatch, failures=failures) + + # Entities are available by default + assert hass.states.get('binary_sensor.test').state == STATE_OFF + + # Mock a disconnect of the Rflink device + disconnect_callback() + + # Wait for dispatch events to propagate + await hass.async_block_till_done() + + # Entity should be unavailable + assert hass.states.get('binary_sensor.test').state == STATE_UNAVAILABLE + + # Reconnect the Rflink device + disconnect_callback() + + # Wait for dispatch events to propagate + await hass.async_block_till_done() + + # Entities should be available again + assert hass.states.get('binary_sensor.test').state == STATE_OFF + + +async def test_off_delay(hass, monkeypatch): + """Test off_delay option.""" + # setup mocking rflink module + event_callback, create, _, disconnect_callback = await mock_rflink( + hass, CONFIG, DOMAIN, monkeypatch) + + # make sure arguments are passed + assert create.call_args_list[0][1]['ignore'] + + events = [] + + on_event = { + 'id': 'test2', + 'command': 'on', + } + + @ha.callback + def callback(event): + """Verify event got called.""" + events.append(event) + + hass.bus.async_listen(EVENT_STATE_CHANGED, callback) + + now = dt_util.utcnow() + # fake time and turn on sensor + future = now + timedelta(seconds=0) + with patch(('homeassistant.helpers.event.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + event_callback(on_event) + await hass.async_block_till_done() + state = hass.states.get('binary_sensor.test2') + assert state.state == STATE_ON + assert len(events) == 1 + + # fake time and turn on sensor again + future = now + timedelta(seconds=15) + with patch(('homeassistant.helpers.event.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + event_callback(on_event) + await hass.async_block_till_done() + state = hass.states.get('binary_sensor.test2') + assert state.state == STATE_ON + assert len(events) == 2 + + # fake time and verify sensor still on (de-bounce) + future = now + timedelta(seconds=35) + with patch(('homeassistant.helpers.event.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + state = hass.states.get('binary_sensor.test2') + assert state.state == STATE_ON + assert len(events) == 2 + + # fake time and verify sensor is off + future = now + timedelta(seconds=45) + with patch(('homeassistant.helpers.event.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + state = hass.states.get('binary_sensor.test2') + assert state.state == STATE_OFF + assert len(events) == 3 diff --git a/tests/components/climate/test_dyson.py b/tests/components/climate/test_dyson.py new file mode 100644 index 00000000000..6e8b63d64c4 --- /dev/null +++ b/tests/components/climate/test_dyson.py @@ -0,0 +1,358 @@ +"""Test the Dyson fan component.""" +import unittest +from unittest import mock + +from libpurecoollink.const import (FocusMode, HeatMode, HeatState, HeatTarget, + TiltState) +from libpurecoollink.dyson_pure_state import DysonPureHotCoolState +from libpurecoollink.dyson_pure_hotcool_link import DysonPureHotCoolLink +from homeassistant.components.climate import dyson +from homeassistant.components import dyson as dyson_parent +from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant + + +class MockDysonState(DysonPureHotCoolState): + """Mock Dyson state.""" + + def __init__(self): + """Create new Mock Dyson State.""" + pass + + +def _get_device_with_no_state(): + """Return a device with no state.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state = None + device.environmental_state = None + return device + + +def _get_device_off(): + """Return a device with state off.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state = mock.Mock() + device.environmental_state = mock.Mock() + return device + + +def _get_device_focus(): + """Return a device with fan state of focus mode.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state.focus_mode = FocusMode.FOCUS_ON.value + return device + + +def _get_device_diffuse(): + """Return a device with fan state of diffuse mode.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state.focus_mode = FocusMode.FOCUS_OFF.value + return device + + +def _get_device_cool(): + """Return a device with state of cooling.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state.tilt = TiltState.TILT_FALSE.value + device.state.focus_mode = FocusMode.FOCUS_OFF.value + device.state.heat_target = HeatTarget.celsius(12) + device.state.heat_mode = HeatMode.HEAT_OFF.value + device.state.heat_state = HeatState.HEAT_STATE_OFF.value + device.environmental_state.temperature = 288 + device.environmental_state.humidity = 53 + return device + + +def _get_device_heat_off(): + """Return a device with state of heat reached target.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state = mock.Mock() + device.state.tilt = TiltState.TILT_FALSE.value + device.state.focus_mode = FocusMode.FOCUS_ON.value + device.state.heat_target = HeatTarget.celsius(20) + device.state.heat_mode = HeatMode.HEAT_ON.value + device.state.heat_state = HeatState.HEAT_STATE_OFF.value + device.environmental_state.temperature = 293 + device.environmental_state.humidity = 53 + return device + + +def _get_device_heat_on(): + """Return a device with state of heating.""" + device = mock.Mock(spec=DysonPureHotCoolLink) + device.name = "Device_name" + device.state = mock.Mock() + device.state.tilt = TiltState.TILT_FALSE.value + device.state.focus_mode = FocusMode.FOCUS_ON.value + device.state.heat_target = HeatTarget.celsius(23) + device.state.heat_mode = HeatMode.HEAT_ON.value + device.state.heat_state = HeatState.HEAT_STATE_ON.value + device.environmental_state.temperature = 289 + device.environmental_state.humidity = 53 + return device + + +class DysonTest(unittest.TestCase): + """Dyson Climate component test class.""" + + def setUp(self): # pylint: disable=invalid-name + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + @mock.patch('libpurecoollink.dyson.DysonAccount.devices', + return_value=[_get_device_heat_on(), _get_device_cool()]) + @mock.patch('libpurecoollink.dyson.DysonAccount.login', return_value=True) + def test_setup_component_with_parent_discovery(self, mocked_login, + mocked_devices): + """Test setup_component using discovery.""" + setup_component(self.hass, dyson_parent.DOMAIN, { + dyson_parent.DOMAIN: { + dyson_parent.CONF_USERNAME: "email", + dyson_parent.CONF_PASSWORD: "password", + dyson_parent.CONF_LANGUAGE: "US", + } + }) + self.assertEqual(len(self.hass.data[dyson.DYSON_DEVICES]), 2) + self.hass.block_till_done() + for m in mocked_devices.return_value: + assert m.add_message_listener.called + + def test_setup_component_without_devices(self): + """Test setup component with no devices.""" + self.hass.data[dyson.DYSON_DEVICES] = [] + add_devices = mock.MagicMock() + dyson.setup_platform(self.hass, None, add_devices) + add_devices.assert_not_called() + + def test_setup_component_with_devices(self): + """Test setup component with valid devices.""" + devices = [ + _get_device_with_no_state(), + _get_device_off(), + _get_device_heat_on() + ] + self.hass.data[dyson.DYSON_DEVICES] = devices + add_devices = mock.MagicMock() + dyson.setup_platform(self.hass, None, add_devices, discovery_info={}) + self.assertTrue(add_devices.called) + + def test_setup_component_with_invalid_devices(self): + """Test setup component with invalid devices.""" + devices = [ + None, + "foo_bar" + ] + self.hass.data[dyson.DYSON_DEVICES] = devices + add_devices = mock.MagicMock() + dyson.setup_platform(self.hass, None, add_devices, discovery_info={}) + add_devices.assert_called_with([]) + + def test_setup_component(self): + """Test setup component with devices.""" + device_fan = _get_device_heat_on() + device_non_fan = _get_device_off() + + def _add_device(devices): + assert len(devices) == 1 + assert devices[0].name == "Device_name" + + self.hass.data[dyson.DYSON_DEVICES] = [device_fan, device_non_fan] + dyson.setup_platform(self.hass, None, _add_device) + + def test_dyson_set_temperature(self): + """Test set climate temperature.""" + device = _get_device_heat_on() + device.temp_unit = TEMP_CELSIUS + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertFalse(entity.should_poll) + + # Without target temp. + kwargs = {} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_not_called() + + kwargs = {ATTR_TEMPERATURE: 23} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_called_with( + heat_mode=HeatMode.HEAT_ON, + heat_target=HeatTarget.celsius(23)) + + # Should clip the target temperature between 1 and 37 inclusive. + kwargs = {ATTR_TEMPERATURE: 50} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_called_with( + heat_mode=HeatMode.HEAT_ON, + heat_target=HeatTarget.celsius(37)) + + kwargs = {ATTR_TEMPERATURE: -5} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_called_with( + heat_mode=HeatMode.HEAT_ON, + heat_target=HeatTarget.celsius(1)) + + def test_dyson_set_temperature_when_cooling_mode(self): + """Test set climate temperature when heating is off.""" + device = _get_device_cool() + device.temp_unit = TEMP_CELSIUS + entity = dyson.DysonPureHotCoolLinkDevice(device) + entity.schedule_update_ha_state = mock.Mock() + + kwargs = {ATTR_TEMPERATURE: 23} + entity.set_temperature(**kwargs) + set_config = device.set_configuration + set_config.assert_called_with( + heat_mode=HeatMode.HEAT_ON, + heat_target=HeatTarget.celsius(23)) + + def test_dyson_set_fan_mode(self): + """Test set fan mode.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertFalse(entity.should_poll) + + entity.set_fan_mode(dyson.STATE_FOCUS) + set_config = device.set_configuration + set_config.assert_called_with(focus_mode=FocusMode.FOCUS_ON) + + entity.set_fan_mode(dyson.STATE_DIFFUSE) + set_config = device.set_configuration + set_config.assert_called_with(focus_mode=FocusMode.FOCUS_OFF) + + def test_dyson_fan_list(self): + """Test get fan list.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(len(entity.fan_list), 2) + self.assertTrue(dyson.STATE_FOCUS in entity.fan_list) + self.assertTrue(dyson.STATE_DIFFUSE in entity.fan_list) + + def test_dyson_fan_mode_focus(self): + """Test fan focus mode.""" + device = _get_device_focus() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_fan_mode, dyson.STATE_FOCUS) + + def test_dyson_fan_mode_diffuse(self): + """Test fan diffuse mode.""" + device = _get_device_diffuse() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_fan_mode, dyson.STATE_DIFFUSE) + + def test_dyson_set_operation_mode(self): + """Test set operation mode.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertFalse(entity.should_poll) + + entity.set_operation_mode(dyson.STATE_HEAT) + set_config = device.set_configuration + set_config.assert_called_with(heat_mode=HeatMode.HEAT_ON) + + entity.set_operation_mode(dyson.STATE_COOL) + set_config = device.set_configuration + set_config.assert_called_with(heat_mode=HeatMode.HEAT_OFF) + + def test_dyson_operation_list(self): + """Test get operation list.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(len(entity.operation_list), 2) + self.assertTrue(dyson.STATE_HEAT in entity.operation_list) + self.assertTrue(dyson.STATE_COOL in entity.operation_list) + + def test_dyson_heat_off(self): + """Test turn off heat.""" + device = _get_device_heat_off() + entity = dyson.DysonPureHotCoolLinkDevice(device) + entity.set_operation_mode(dyson.STATE_COOL) + set_config = device.set_configuration + set_config.assert_called_with(heat_mode=HeatMode.HEAT_OFF) + + def test_dyson_heat_on(self): + """Test turn on heat.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + entity.set_operation_mode(dyson.STATE_HEAT) + set_config = device.set_configuration + set_config.assert_called_with(heat_mode=HeatMode.HEAT_ON) + + def test_dyson_heat_value_on(self): + """Test get heat value on.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_operation, dyson.STATE_HEAT) + + def test_dyson_heat_value_off(self): + """Test get heat value off.""" + device = _get_device_cool() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_operation, dyson.STATE_COOL) + + def test_dyson_heat_value_idle(self): + """Test get heat value idle.""" + device = _get_device_heat_off() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_operation, dyson.STATE_IDLE) + + def test_on_message(self): + """Test when message is received.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + entity.schedule_update_ha_state = mock.Mock() + entity.on_message(MockDysonState()) + entity.schedule_update_ha_state.assert_called_with() + + def test_general_properties(self): + """Test properties of entity.""" + device = _get_device_with_no_state() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.should_poll, False) + self.assertEqual(entity.supported_features, dyson.SUPPORT_FLAGS) + self.assertEqual(entity.temperature_unit, TEMP_CELSIUS) + + def test_property_current_humidity(self): + """Test properties of current humidity.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_humidity, 53) + + def test_property_current_humidity_with_invalid_env_state(self): + """Test properties of current humidity with invalid env state.""" + device = _get_device_off() + device.environmental_state.humidity = 0 + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_humidity, None) + + def test_property_current_humidity_without_env_state(self): + """Test properties of current humidity without env state.""" + device = _get_device_with_no_state() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.current_humidity, None) + + def test_property_current_temperature(self): + """Test properties of current temperature.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + # Result should be in celsius, hence then subtraction of 273. + self.assertEqual(entity.current_temperature, 289 - 273) + + def test_property_target_temperature(self): + """Test properties of target temperature.""" + device = _get_device_heat_on() + entity = dyson.DysonPureHotCoolLinkDevice(device) + self.assertEqual(entity.target_temperature, 23) diff --git a/tests/components/climate/test_mqtt.py b/tests/components/climate/test_mqtt.py index c63dbf26690..16fe0a6639d 100644 --- a/tests/components/climate/test_mqtt.py +++ b/tests/components/climate/test_mqtt.py @@ -119,14 +119,14 @@ class TestMQTTClimate(unittest.TestCase): assert setup_component(self.hass, climate.DOMAIN, config) state = self.hass.states.get(ENTITY_CLIMATE) - self.assertEqual("off", state.attributes.get('operation_mode')) - self.assertEqual("off", state.state) + self.assertEqual(None, state.attributes.get('operation_mode')) + self.assertEqual("unknown", state.state) common.set_operation_mode(self.hass, "cool", ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) - self.assertEqual("off", state.attributes.get('operation_mode')) - self.assertEqual("off", state.state) + self.assertEqual(None, state.attributes.get('operation_mode')) + self.assertEqual("unknown", state.state) fire_mqtt_message(self.hass, 'mode-state', 'cool') self.hass.block_till_done() @@ -189,12 +189,12 @@ class TestMQTTClimate(unittest.TestCase): assert setup_component(self.hass, climate.DOMAIN, config) state = self.hass.states.get(ENTITY_CLIMATE) - self.assertEqual("low", state.attributes.get('fan_mode')) + self.assertEqual(None, state.attributes.get('fan_mode')) common.set_fan_mode(self.hass, 'high', ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) - self.assertEqual("low", state.attributes.get('fan_mode')) + self.assertEqual(None, state.attributes.get('fan_mode')) fire_mqtt_message(self.hass, 'fan-state', 'high') self.hass.block_till_done() @@ -237,12 +237,12 @@ class TestMQTTClimate(unittest.TestCase): assert setup_component(self.hass, climate.DOMAIN, config) state = self.hass.states.get(ENTITY_CLIMATE) - self.assertEqual("off", state.attributes.get('swing_mode')) + self.assertEqual(None, state.attributes.get('swing_mode')) common.set_swing_mode(self.hass, 'on', ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) - self.assertEqual("off", state.attributes.get('swing_mode')) + self.assertEqual(None, state.attributes.get('swing_mode')) fire_mqtt_message(self.hass, 'swing-state', 'on') self.hass.block_till_done() @@ -310,14 +310,14 @@ class TestMQTTClimate(unittest.TestCase): assert setup_component(self.hass, climate.DOMAIN, config) state = self.hass.states.get(ENTITY_CLIMATE) - self.assertEqual(21, state.attributes.get('temperature')) + self.assertEqual(None, state.attributes.get('temperature')) common.set_operation_mode(self.hass, 'heat', ENTITY_CLIMATE) self.hass.block_till_done() common.set_temperature(self.hass, temperature=47, entity_id=ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) - self.assertEqual(21, state.attributes.get('temperature')) + self.assertEqual(None, state.attributes.get('temperature')) fire_mqtt_message(self.hass, 'temperature-state', '1701') self.hass.block_till_done() @@ -539,28 +539,28 @@ class TestMQTTClimate(unittest.TestCase): # Operation Mode state = self.hass.states.get(ENTITY_CLIMATE) - self.assertEqual("off", state.attributes.get('operation_mode')) + self.assertEqual(None, state.attributes.get('operation_mode')) fire_mqtt_message(self.hass, 'mode-state', '"cool"') self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual("cool", state.attributes.get('operation_mode')) # Fan Mode - self.assertEqual("low", state.attributes.get('fan_mode')) + self.assertEqual(None, state.attributes.get('fan_mode')) fire_mqtt_message(self.hass, 'fan-state', '"high"') self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('high', state.attributes.get('fan_mode')) # Swing Mode - self.assertEqual("off", state.attributes.get('swing_mode')) + self.assertEqual(None, state.attributes.get('swing_mode')) fire_mqtt_message(self.hass, 'swing-state', '"on"') self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual("on", state.attributes.get('swing_mode')) # Temperature - with valid value - self.assertEqual(21, state.attributes.get('temperature')) + self.assertEqual(None, state.attributes.get('temperature')) fire_mqtt_message(self.hass, 'temperature-state', '"1031"') self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) @@ -653,6 +653,19 @@ class TestMQTTClimate(unittest.TestCase): self.assertIsInstance(max_temp, float) self.assertEqual(60, max_temp) + def test_temp_step_custom(self): + """Test a custom temp step.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config['climate']['temp_step'] = 0.01 + + assert setup_component(self.hass, climate.DOMAIN, config) + + state = self.hass.states.get(ENTITY_CLIMATE) + temp_step = state.attributes.get('target_temp_step') + + self.assertIsInstance(temp_step, float) + self.assertEqual(0.01, temp_step) + async def test_discovery_removal_climate(hass, mqtt_mock, caplog): """Test removal of discovered climate.""" diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 5d4b356b9b2..e27760bd6ed 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -7,6 +7,7 @@ from jose import jwt from homeassistant.components.cloud import ( DOMAIN, auth_api, iot, STORAGE_ENABLE_GOOGLE, STORAGE_ENABLE_ALEXA) +from homeassistant.util import dt as dt_util from tests.common import mock_coro @@ -352,24 +353,89 @@ async def test_websocket_status_not_logged_in(hass, hass_ws_client): } -async def test_websocket_subscription(hass, hass_ws_client, aioclient_mock, - mock_auth): - """Test querying the status.""" - aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'return': 'value'}) +async def test_websocket_subscription_reconnect( + hass, hass_ws_client, aioclient_mock, mock_auth): + """Test querying the status and connecting because valid account.""" + aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'}) + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': dt_util.utcnow().date().isoformat() + }, 'test') + client = await hass_ws_client(hass) + + with patch( + 'homeassistant.components.cloud.auth_api.renew_access_token' + ) as mock_renew, patch( + 'homeassistant.components.cloud.iot.CloudIoT.connect' + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/subscription' + }) + response = await client.receive_json() + + assert response['result'] == { + 'provider': 'stripe' + } + assert len(mock_renew.mock_calls) == 1 + assert len(mock_connect.mock_calls) == 1 + + +async def test_websocket_subscription_no_reconnect_if_connected( + hass, hass_ws_client, aioclient_mock, mock_auth): + """Test querying the status and not reconnecting because still expired.""" + aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'}) + hass.data[DOMAIN].iot.state = iot.STATE_CONNECTED + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': dt_util.utcnow().date().isoformat() + }, 'test') + client = await hass_ws_client(hass) + + with patch( + 'homeassistant.components.cloud.auth_api.renew_access_token' + ) as mock_renew, patch( + 'homeassistant.components.cloud.iot.CloudIoT.connect' + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/subscription' + }) + response = await client.receive_json() + + assert response['result'] == { + 'provider': 'stripe' + } + assert len(mock_renew.mock_calls) == 0 + assert len(mock_connect.mock_calls) == 0 + + +async def test_websocket_subscription_no_reconnect_if_expired( + hass, hass_ws_client, aioclient_mock, mock_auth): + """Test querying the status and not reconnecting because still expired.""" + aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'}) hass.data[DOMAIN].id_token = jwt.encode({ 'email': 'hello@home-assistant.io', 'custom:sub-exp': '2018-01-03' }, 'test') client = await hass_ws_client(hass) - await client.send_json({ - 'id': 5, - 'type': 'cloud/subscription' - }) - response = await client.receive_json() + + with patch( + 'homeassistant.components.cloud.auth_api.renew_access_token' + ) as mock_renew, patch( + 'homeassistant.components.cloud.iot.CloudIoT.connect' + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/subscription' + }) + response = await client.receive_json() assert response['result'] == { - 'return': 'value' + 'provider': 'stripe' } + assert len(mock_renew.mock_calls) == 1 + assert len(mock_connect.mock_calls) == 1 async def test_websocket_subscription_fail(hass, hass_ws_client, diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 8695830eae9..61518f0f0e8 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -155,14 +155,14 @@ def test_subscription_expired(hass): with patch.object(cl, '_decode_claims', return_value=token_val), \ patch('homeassistant.util.dt.utcnow', return_value=utcnow().replace( - year=2017, month=11, day=15, hour=23, minute=59, + year=2017, month=11, day=19, hour=23, minute=59, second=59)): assert not cl.subscription_expired with patch.object(cl, '_decode_claims', return_value=token_val), \ patch('homeassistant.util.dt.utcnow', return_value=utcnow().replace( - year=2017, month=11, day=16, hour=0, minute=0, + year=2017, month=11, day=20, hour=0, minute=0, second=0)): assert cl.subscription_expired diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py index cd04eedf08e..f7e348e8476 100644 --- a/tests/components/config/test_auth.py +++ b/tests/components/config/test_auth.py @@ -6,7 +6,7 @@ import pytest from homeassistant.auth import models as auth_models from homeassistant.components.config import auth as auth_config -from tests.common import MockUser, CLIENT_ID +from tests.common import MockGroup, MockUser, CLIENT_ID @pytest.fixture(autouse=True) @@ -39,10 +39,13 @@ async def test_list_requires_owner(hass, hass_ws_client, hass_access_token): async def test_list(hass, hass_ws_client): """Test get users.""" + group = MockGroup().add_to_hass(hass) + owner = MockUser( id='abc', name='Test Owner', is_owner=True, + groups=[group], ).add_to_hass(hass) owner.credentials.append(auth_models.Credentials( @@ -61,6 +64,7 @@ async def test_list(hass, hass_ws_client): id='hij', name='Inactive User', is_active=False, + groups=[group], ).add_to_hass(hass) refresh_token = await hass.auth.async_create_refresh_token( @@ -83,6 +87,7 @@ async def test_list(hass, hass_ws_client): 'is_owner': True, 'is_active': True, 'system_generated': False, + 'group_ids': [group.id for group in owner.groups], 'credentials': [{'type': 'homeassistant'}] } assert data[1] == { @@ -91,6 +96,7 @@ async def test_list(hass, hass_ws_client): 'is_owner': False, 'is_active': True, 'system_generated': True, + 'group_ids': [], 'credentials': [], } assert data[2] == { @@ -99,6 +105,7 @@ async def test_list(hass, hass_ws_client): 'is_owner': False, 'is_active': False, 'system_generated': False, + 'group_ids': [group.id for group in inactive.groups], 'credentials': [], } diff --git a/tests/components/cover/test_deconz.py b/tests/components/cover/test_deconz.py index 60de9cffdc1..e9c630823bd 100644 --- a/tests/components/cover/test_deconz.py +++ b/tests/components/cover/test_deconz.py @@ -13,7 +13,15 @@ SUPPORTED_COVERS = { "id": "Cover 1 id", "name": "Cover 1 name", "type": "Level controllable output", - "state": {} + "state": {}, + "modelid": "Not zigbee spec" + }, + "2": { + "id": "Cover 2 id", + "name": "Cover 2 name", + "type": "Window covering device", + "state": {}, + "modelid": "lumi.curtain" } } @@ -62,7 +70,7 @@ async def test_cover(hass): await setup_bridge(hass, {"lights": SUPPORTED_COVERS}) assert "cover.cover_1_name" in hass.data[deconz.DATA_DECONZ_ID] assert len(SUPPORTED_COVERS) == len(COVER_TYPES) - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 async def test_add_new_cover(hass): diff --git a/tests/components/cover/test_mqtt.py b/tests/components/cover/test_mqtt.py index 355f620520a..282b1d2873f 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/cover/test_mqtt.py @@ -1,4 +1,5 @@ """The tests for the MQTT cover platform.""" +import json import unittest from homeassistant.components import cover, mqtt @@ -615,7 +616,7 @@ class TestCoverMQTT(unittest.TestCase): 'tilt-command-topic', 'tilt-status-topic', 0, False, 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, False, None, 100, 0, 0, 100, False, False, None, None, None, - None) + None, None) self.assertEqual(44, mqtt_cover.find_percentage_in_range(44)) @@ -626,7 +627,7 @@ class TestCoverMQTT(unittest.TestCase): 'tilt-command-topic', 'tilt-status-topic', 0, False, 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, False, None, 180, 80, 80, 180, False, False, None, None, None, - None) + None, None) self.assertEqual(40, mqtt_cover.find_percentage_in_range(120)) @@ -637,7 +638,7 @@ class TestCoverMQTT(unittest.TestCase): 'tilt-command-topic', 'tilt-status-topic', 0, False, 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, False, None, 100, 0, 0, 100, False, True, None, None, None, - None) + None, None) self.assertEqual(56, mqtt_cover.find_percentage_in_range(44)) @@ -648,7 +649,7 @@ class TestCoverMQTT(unittest.TestCase): 'tilt-command-topic', 'tilt-status-topic', 0, False, 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, False, None, 180, 80, 80, 180, False, True, None, None, None, - None) + None, None) self.assertEqual(60, mqtt_cover.find_percentage_in_range(120)) @@ -659,7 +660,7 @@ class TestCoverMQTT(unittest.TestCase): 'tilt-command-topic', 'tilt-status-topic', 0, False, 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, False, None, 100, 0, 0, 100, False, False, None, None, None, - None) + None, None) self.assertEqual(44, mqtt_cover.find_in_range_from_percent(44)) @@ -670,7 +671,7 @@ class TestCoverMQTT(unittest.TestCase): 'tilt-command-topic', 'tilt-status-topic', 0, False, 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, False, None, 180, 80, 80, 180, False, False, None, None, None, - None) + None, None) self.assertEqual(120, mqtt_cover.find_in_range_from_percent(40)) @@ -681,7 +682,7 @@ class TestCoverMQTT(unittest.TestCase): 'tilt-command-topic', 'tilt-status-topic', 0, False, 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, False, None, 100, 0, 0, 100, False, True, None, None, None, - None) + None, None) self.assertEqual(44, mqtt_cover.find_in_range_from_percent(56)) @@ -692,7 +693,7 @@ class TestCoverMQTT(unittest.TestCase): 'tilt-command-topic', 'tilt-status-topic', 0, False, 'OPEN', 'CLOSE', 'OPEN', 'CLOSE', 'STOP', None, None, False, None, 180, 80, 80, 180, False, True, None, None, None, - None) + None, None) self.assertEqual(120, mqtt_cover.find_in_range_from_percent(60)) @@ -810,3 +811,42 @@ async def test_unique_id(hass): await hass.async_block_till_done() assert len(hass.states.async_entity_ids(cover.DOMAIN)) == 1 + + +async def test_entity_device_info_with_identifier(hass, mqtt_mock): + """Test MQTT cover device registry integration.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + data = json.dumps({ + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'command_topic': 'test-command-topic', + 'device': { + 'identifiers': ['helloworld'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }, + 'unique_id': 'veryunique' + }) + async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.identifiers == {('mqtt', 'helloworld')} + assert device.connections == {('mac', "02:5b:26:a8:dc:12")} + assert device.manufacturer == 'Whatever' + assert device.name == 'Beer' + assert device.model == 'Glass' + assert device.sw_version == '0.1-beta' diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py index 9b0a5cd9052..3de8e969140 100644 --- a/tests/components/emulated_hue/test_init.py +++ b/tests/components/emulated_hue/test_init.py @@ -1,7 +1,5 @@ """Test the Emulated Hue component.""" -import json - -from unittest.mock import patch, Mock, mock_open, MagicMock +from unittest.mock import patch, Mock, MagicMock from homeassistant.components.emulated_hue import Config @@ -14,30 +12,30 @@ def test_config_google_home_entity_id_to_number(): 'type': 'google_home' }) - mop = mock_open(read_data=json.dumps({'1': 'light.test2'})) - handle = mop() + with patch('homeassistant.components.emulated_hue.load_json', + return_value={'1': 'light.test2'}) as json_loader: + with patch('homeassistant.components.emulated_hue' + '.save_json') as json_saver: + number = conf.entity_id_to_number('light.test') + assert number == '2' - with patch('homeassistant.util.json.open', mop, create=True): - with patch('homeassistant.util.json.os.open', return_value=0): - with patch('homeassistant.util.json.os.replace'): - number = conf.entity_id_to_number('light.test') - assert number == '2' - assert handle.write.call_count == 1 - assert json.loads(handle.write.mock_calls[0][1][0]) == { - '1': 'light.test2', - '2': 'light.test', - } + assert json_saver.mock_calls[0][1][1] == { + '1': 'light.test2', '2': 'light.test' + } - number = conf.entity_id_to_number('light.test') - assert number == '2' - assert handle.write.call_count == 1 + assert json_saver.call_count == 1 + assert json_loader.call_count == 1 - number = conf.entity_id_to_number('light.test2') - assert number == '1' - assert handle.write.call_count == 1 + number = conf.entity_id_to_number('light.test') + assert number == '2' + assert json_saver.call_count == 1 - entity_id = conf.number_to_entity_id('1') - assert entity_id == 'light.test2' + number = conf.entity_id_to_number('light.test2') + assert number == '1' + assert json_saver.call_count == 1 + + entity_id = conf.number_to_entity_id('1') + assert entity_id == 'light.test2' def test_config_google_home_entity_id_to_number_altered(): @@ -48,30 +46,30 @@ def test_config_google_home_entity_id_to_number_altered(): 'type': 'google_home' }) - mop = mock_open(read_data=json.dumps({'21': 'light.test2'})) - handle = mop() + with patch('homeassistant.components.emulated_hue.load_json', + return_value={'21': 'light.test2'}) as json_loader: + with patch('homeassistant.components.emulated_hue' + '.save_json') as json_saver: + number = conf.entity_id_to_number('light.test') + assert number == '22' + assert json_saver.call_count == 1 + assert json_loader.call_count == 1 - with patch('homeassistant.util.json.open', mop, create=True): - with patch('homeassistant.util.json.os.open', return_value=0): - with patch('homeassistant.util.json.os.replace'): - number = conf.entity_id_to_number('light.test') - assert number == '22' - assert handle.write.call_count == 1 - assert json.loads(handle.write.mock_calls[0][1][0]) == { - '21': 'light.test2', - '22': 'light.test', - } + assert json_saver.mock_calls[0][1][1] == { + '21': 'light.test2', + '22': 'light.test', + } - number = conf.entity_id_to_number('light.test') - assert number == '22' - assert handle.write.call_count == 1 + number = conf.entity_id_to_number('light.test') + assert number == '22' + assert json_saver.call_count == 1 - number = conf.entity_id_to_number('light.test2') - assert number == '21' - assert handle.write.call_count == 1 + number = conf.entity_id_to_number('light.test2') + assert number == '21' + assert json_saver.call_count == 1 - entity_id = conf.number_to_entity_id('21') - assert entity_id == 'light.test2' + entity_id = conf.number_to_entity_id('21') + assert entity_id == 'light.test2' def test_config_google_home_entity_id_to_number_empty(): @@ -82,29 +80,29 @@ def test_config_google_home_entity_id_to_number_empty(): 'type': 'google_home' }) - mop = mock_open(read_data='') - handle = mop() + with patch('homeassistant.components.emulated_hue.load_json', + return_value={}) as json_loader: + with patch('homeassistant.components.emulated_hue' + '.save_json') as json_saver: + number = conf.entity_id_to_number('light.test') + assert number == '1' + assert json_saver.call_count == 1 + assert json_loader.call_count == 1 - with patch('homeassistant.util.json.open', mop, create=True): - with patch('homeassistant.util.json.os.open', return_value=0): - with patch('homeassistant.util.json.os.replace'): - number = conf.entity_id_to_number('light.test') - assert number == '1' - assert handle.write.call_count == 1 - assert json.loads(handle.write.mock_calls[0][1][0]) == { - '1': 'light.test', - } + assert json_saver.mock_calls[0][1][1] == { + '1': 'light.test', + } - number = conf.entity_id_to_number('light.test') - assert number == '1' - assert handle.write.call_count == 1 + number = conf.entity_id_to_number('light.test') + assert number == '1' + assert json_saver.call_count == 1 - number = conf.entity_id_to_number('light.test2') - assert number == '2' - assert handle.write.call_count == 2 + number = conf.entity_id_to_number('light.test2') + assert number == '2' + assert json_saver.call_count == 2 - entity_id = conf.number_to_entity_id('2') - assert entity_id == 'light.test2' + entity_id = conf.number_to_entity_id('2') + assert entity_id == 'light.test2' def test_config_alexa_entity_id_to_number(): diff --git a/tests/components/fan/test_mqtt.py b/tests/components/fan/test_mqtt.py index 7434e5aa1c9..e2742eeba7d 100644 --- a/tests/components/fan/test_mqtt.py +++ b/tests/components/fan/test_mqtt.py @@ -1,4 +1,5 @@ """Test MQTT fans.""" +import json import unittest from homeassistant.setup import setup_component, async_setup_component @@ -8,7 +9,7 @@ from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNAVAILABLE from tests.common import ( mock_mqtt_component, async_fire_mqtt_message, fire_mqtt_message, - get_test_home_assistant, async_mock_mqtt_component) + get_test_home_assistant, async_mock_mqtt_component, MockConfigEntry) class TestMqttFan(unittest.TestCase): @@ -108,7 +109,8 @@ class TestMqttFan(unittest.TestCase): async def test_discovery_removal_fan(hass, mqtt_mock, caplog): """Test removal of discovered fan.""" - await async_start(hass, 'homeassistant', {}) + entry = MockConfigEntry(domain='mqtt') + await async_start(hass, 'homeassistant', {}, entry) data = ( '{ "name": "Beer",' ' "command_topic": "test_topic" }' @@ -150,3 +152,42 @@ async def test_unique_id(hass): await hass.async_block_till_done() assert len(hass.states.async_entity_ids(fan.DOMAIN)) == 1 + + +async def test_entity_device_info_with_identifier(hass, mqtt_mock): + """Test MQTT fan device registry integration.""" + entry = MockConfigEntry(domain='mqtt') + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + data = json.dumps({ + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'command_topic': 'test-command-topic', + 'device': { + 'identifiers': ['helloworld'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }, + 'unique_id': 'veryunique' + }) + async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.identifiers == {('mqtt', 'helloworld')} + assert device.connections == {('mac', "02:5b:26:a8:dc:12")} + assert device.manufacturer == 'Whatever' + assert device.name == 'Beer' + assert device.model == 'Glass' + assert device.sw_version == '0.1-beta' diff --git a/tests/components/geo_location/test_geo_json_events.py b/tests/components/geo_location/test_geo_json_events.py index 5ce508289dd..00fc9f8c996 100644 --- a/tests/components/geo_location/test_geo_json_events.py +++ b/tests/components/geo_location/test_geo_json_events.py @@ -3,7 +3,9 @@ import unittest from unittest import mock from unittest.mock import patch, MagicMock +import homeassistant from homeassistant.components import geo_location +from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.components.geo_location.geo_json_events import \ SCAN_INTERVAL, ATTR_EXTERNAL_ID from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_START, \ @@ -84,7 +86,8 @@ class TestGeoJsonPlatform(unittest.TestCase): assert state.attributes == { ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", - ATTR_UNIT_OF_MEASUREMENT: "km"} + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'geo_json_events'} self.assertAlmostEqual(float(state.state), 15.5) state = self.hass.states.get("geo_location.title_2") @@ -93,7 +96,8 @@ class TestGeoJsonPlatform(unittest.TestCase): assert state.attributes == { ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", - ATTR_UNIT_OF_MEASUREMENT: "km"} + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'geo_json_events'} self.assertAlmostEqual(float(state.state), 20.5) state = self.hass.states.get("geo_location.title_3") @@ -102,7 +106,8 @@ class TestGeoJsonPlatform(unittest.TestCase): assert state.attributes == { ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2, ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", - ATTR_UNIT_OF_MEASUREMENT: "km"} + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'geo_json_events'} self.assertAlmostEqual(float(state.state), 25.5) # Simulate an update - one existing, one new entry, @@ -134,3 +139,90 @@ class TestGeoJsonPlatform(unittest.TestCase): all_states = self.hass.states.all() assert len(all_states) == 0 + + @mock.patch('geojson_client.generic_feed.GenericFeed') + def test_setup_race_condition(self, mock_feed): + """Test a particular race condition experienced.""" + # 1. Feed returns 1 entry -> Feed manager creates 1 entity. + # 2. Feed returns error -> Feed manager removes 1 entity. + # However, this stayed on and kept listening for dispatcher signals. + # 3. Feed returns 1 entry -> Feed manager creates 1 entity. + # 4. Feed returns 1 entry -> Feed manager updates 1 entity. + # Internally, the previous entity is updating itself, too. + # 5. Feed returns error -> Feed manager removes 1 entity. + # There are now 2 entities trying to remove themselves from HA, but + # the second attempt fails of course. + + # Set up some mock feed entries for this test. + mock_entry_1 = self._generate_mock_feed_entry('1234', 'Title 1', 15.5, + (-31.0, 150.0)) + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] + + utcnow = dt_util.utcnow() + # Patching 'utcnow' to gain more control over the timed update. + with patch('homeassistant.util.dt.utcnow', return_value=utcnow): + with assert_setup_component(1, geo_location.DOMAIN): + self.assertTrue(setup_component(self.hass, geo_location.DOMAIN, + CONFIG)) + + # This gives us the ability to assert the '_delete_callback' + # has been called while still executing it. + original_delete_callback = homeassistant.components\ + .geo_location.geo_json_events.GeoJsonLocationEvent\ + ._delete_callback + + def mock_delete_callback(entity): + original_delete_callback(entity) + + with patch('homeassistant.components.geo_location' + '.geo_json_events.GeoJsonLocationEvent' + '._delete_callback', + side_effect=mock_delete_callback, + autospec=True) as mocked_delete_callback: + + # Artificially trigger update. + self.hass.bus.fire(EVENT_HOMEASSISTANT_START) + # Collect events. + self.hass.block_till_done() + + all_states = self.hass.states.all() + assert len(all_states) == 1 + + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + fire_time_changed(self.hass, utcnow + SCAN_INTERVAL) + self.hass.block_till_done() + + assert mocked_delete_callback.call_count == 1 + all_states = self.hass.states.all() + assert len(all_states) == 0 + + # Simulate an update - 1 entry + mock_feed.return_value.update.return_value = 'OK', [ + mock_entry_1] + fire_time_changed(self.hass, utcnow + 2 * SCAN_INTERVAL) + self.hass.block_till_done() + + all_states = self.hass.states.all() + assert len(all_states) == 1 + + # Simulate an update - 1 entry + mock_feed.return_value.update.return_value = 'OK', [ + mock_entry_1] + fire_time_changed(self.hass, utcnow + 3 * SCAN_INTERVAL) + self.hass.block_till_done() + + all_states = self.hass.states.all() + assert len(all_states) == 1 + + # Reset mocked method for the next test. + mocked_delete_callback.reset_mock() + + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + fire_time_changed(self.hass, utcnow + 4 * SCAN_INTERVAL) + self.hass.block_till_done() + + assert mocked_delete_callback.call_count == 1 + all_states = self.hass.states.all() + assert len(all_states) == 0 diff --git a/tests/components/geo_location/test_init.py b/tests/components/geo_location/test_init.py index 54efe977bf9..09030354901 100644 --- a/tests/components/geo_location/test_init.py +++ b/tests/components/geo_location/test_init.py @@ -1,4 +1,6 @@ """The tests for the geo location component.""" +import pytest + from homeassistant.components import geo_location from homeassistant.components.geo_location import GeoLocationEvent from homeassistant.setup import async_setup_component @@ -18,3 +20,5 @@ async def test_event(hass): assert entity.distance is None assert entity.latitude is None assert entity.longitude is None + with pytest.raises(NotImplementedError): + assert entity.source is None diff --git a/tests/components/geo_location/test_nsw_rural_fire_service_feed.py b/tests/components/geo_location/test_nsw_rural_fire_service_feed.py new file mode 100644 index 00000000000..92c4bae1931 --- /dev/null +++ b/tests/components/geo_location/test_nsw_rural_fire_service_feed.py @@ -0,0 +1,175 @@ +"""The tests for the geojson platform.""" +import datetime +import unittest +from unittest import mock +from unittest.mock import patch, MagicMock + +from homeassistant.components import geo_location +from homeassistant.components.geo_location import ATTR_SOURCE +from homeassistant.components.geo_location.nsw_rural_fire_service_feed import \ + ATTR_EXTERNAL_ID, SCAN_INTERVAL, ATTR_CATEGORY, ATTR_FIRE, ATTR_LOCATION, \ + ATTR_COUNCIL_AREA, ATTR_STATUS, ATTR_TYPE, ATTR_SIZE, \ + ATTR_RESPONSIBLE_AGENCY, ATTR_PUBLICATION_DATE +from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_START, \ + CONF_RADIUS, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_FRIENDLY_NAME, \ + ATTR_UNIT_OF_MEASUREMENT, ATTR_ATTRIBUTION +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant, assert_setup_component, \ + fire_time_changed +import homeassistant.util.dt as dt_util + +URL = 'http://geo.json.local/geo_json_events.json' +CONFIG = { + geo_location.DOMAIN: [ + { + 'platform': 'nsw_rural_fire_service_feed', + CONF_URL: URL, + CONF_RADIUS: 200 + } + ] +} + + +class TestGeoJsonPlatform(unittest.TestCase): + """Test the geojson platform.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @staticmethod + def _generate_mock_feed_entry(external_id, title, distance_to_home, + coordinates, category=None, location=None, + attribution=None, publication_date=None, + council_area=None, status=None, + entry_type=None, fire=True, size=None, + responsible_agency=None): + """Construct a mock feed entry for testing purposes.""" + feed_entry = MagicMock() + feed_entry.external_id = external_id + feed_entry.title = title + feed_entry.distance_to_home = distance_to_home + feed_entry.coordinates = coordinates + feed_entry.category = category + feed_entry.location = location + feed_entry.attribution = attribution + feed_entry.publication_date = publication_date + feed_entry.council_area = council_area + feed_entry.status = status + feed_entry.type = entry_type + feed_entry.fire = fire + feed_entry.size = size + feed_entry.responsible_agency = responsible_agency + return feed_entry + + @mock.patch('geojson_client.nsw_rural_fire_service_feed.' + 'NswRuralFireServiceFeed') + def test_setup(self, mock_feed): + """Test the general setup of the platform.""" + # Set up some mock feed entries for this test. + mock_entry_1 = self._generate_mock_feed_entry( + '1234', 'Title 1', 15.5, (-31.0, 150.0), category='Category 1', + location='Location 1', attribution='Attribution 1', + publication_date=datetime.datetime(2018, 9, 22, 8, 0, + tzinfo=datetime.timezone.utc), + council_area='Council Area 1', status='Status 1', + entry_type='Type 1', size='Size 1', responsible_agency='Agency 1') + mock_entry_2 = self._generate_mock_feed_entry('2345', 'Title 2', 20.5, + (-31.1, 150.1), + fire=False) + mock_entry_3 = self._generate_mock_feed_entry('3456', 'Title 3', 25.5, + (-31.2, 150.2)) + mock_entry_4 = self._generate_mock_feed_entry('4567', 'Title 4', 12.5, + (-31.3, 150.3)) + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1, + mock_entry_2, + mock_entry_3] + + utcnow = dt_util.utcnow() + # Patching 'utcnow' to gain more control over the timed update. + with patch('homeassistant.util.dt.utcnow', return_value=utcnow): + with assert_setup_component(1, geo_location.DOMAIN): + self.assertTrue(setup_component(self.hass, geo_location.DOMAIN, + CONFIG)) + # Artificially trigger update. + self.hass.bus.fire(EVENT_HOMEASSISTANT_START) + # Collect events. + self.hass.block_till_done() + + all_states = self.hass.states.all() + assert len(all_states) == 3 + + state = self.hass.states.get("geo_location.title_1") + self.assertIsNotNone(state) + assert state.name == "Title 1" + assert state.attributes == { + ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, + ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", + ATTR_CATEGORY: "Category 1", ATTR_LOCATION: "Location 1", + ATTR_ATTRIBUTION: "Attribution 1", + ATTR_PUBLICATION_DATE: + datetime.datetime(2018, 9, 22, 8, 0, + tzinfo=datetime.timezone.utc), + ATTR_FIRE: True, + ATTR_COUNCIL_AREA: 'Council Area 1', + ATTR_STATUS: 'Status 1', ATTR_TYPE: 'Type 1', + ATTR_SIZE: 'Size 1', ATTR_RESPONSIBLE_AGENCY: 'Agency 1', + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'nsw_rural_fire_service_feed'} + self.assertAlmostEqual(float(state.state), 15.5) + + state = self.hass.states.get("geo_location.title_2") + self.assertIsNotNone(state) + assert state.name == "Title 2" + assert state.attributes == { + ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, + ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", + ATTR_FIRE: False, + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'nsw_rural_fire_service_feed'} + self.assertAlmostEqual(float(state.state), 20.5) + + state = self.hass.states.get("geo_location.title_3") + self.assertIsNotNone(state) + assert state.name == "Title 3" + assert state.attributes == { + ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2, + ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", + ATTR_FIRE: True, + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'nsw_rural_fire_service_feed'} + self.assertAlmostEqual(float(state.state), 25.5) + + # Simulate an update - one existing, one new entry, + # one outdated entry + mock_feed.return_value.update.return_value = 'OK', [ + mock_entry_1, mock_entry_4, mock_entry_3] + fire_time_changed(self.hass, utcnow + SCAN_INTERVAL) + self.hass.block_till_done() + + all_states = self.hass.states.all() + assert len(all_states) == 3 + + # Simulate an update - empty data, but successful update, + # so no changes to entities. + mock_feed.return_value.update.return_value = 'OK_NO_DATA', None + # mock_restdata.return_value.data = None + fire_time_changed(self.hass, utcnow + + 2 * SCAN_INTERVAL) + self.hass.block_till_done() + + all_states = self.hass.states.all() + assert len(all_states) == 3 + + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + fire_time_changed(self.hass, utcnow + + 2 * SCAN_INTERVAL) + self.hass.block_till_done() + + all_states = self.hass.states.all() + assert len(all_states) == 0 diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 55c8a7778cb..104d1427dc9 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -108,6 +108,36 @@ class TestComponentsGroup(unittest.TestCase): group_state = self.hass.states.get(test_group.entity_id) self.assertEqual(STATE_ON, group_state.state) + def test_allgroup_stays_off_if_all_are_off_and_one_turns_on(self): + """Group with all: true, stay off if one device turns on.""" + self.hass.states.set('light.Bowl', STATE_OFF) + self.hass.states.set('light.Ceiling', STATE_OFF) + test_group = group.Group.create_group( + self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False, + mode=True) + + # Turn one on + self.hass.states.set('light.Ceiling', STATE_ON) + self.hass.block_till_done() + + group_state = self.hass.states.get(test_group.entity_id) + self.assertEqual(STATE_OFF, group_state.state) + + def test_allgroup_turn_on_if_last_turns_on(self): + """Group with all: true, turn on if all devices are on.""" + self.hass.states.set('light.Bowl', STATE_ON) + self.hass.states.set('light.Ceiling', STATE_OFF) + test_group = group.Group.create_group( + self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False, + mode=True) + + # Turn one on + self.hass.states.set('light.Ceiling', STATE_ON) + self.hass.block_till_done() + + group_state = self.hass.states.get(test_group.entity_id) + self.assertEqual(STATE_ON, group_state.state) + def test_is_on(self): """Test is_on method.""" self.hass.states.set('light.Bowl', STATE_ON) diff --git a/tests/components/homekit/conftest.py b/tests/components/homekit/conftest.py index 55e02de7526..95829435d0e 100644 --- a/tests/components/homekit/conftest.py +++ b/tests/components/homekit/conftest.py @@ -3,6 +3,9 @@ from unittest.mock import patch import pytest +from homeassistant.components.homekit.const import EVENT_HOMEKIT_CHANGED +from homeassistant.core import callback as ha_callback + from pyhap.accessory_driver import AccessoryDriver @@ -14,3 +17,13 @@ def hk_driver(): patch('pyhap.accessory_driver.HAPServer'), \ patch('pyhap.accessory_driver.AccessoryDriver.publish'): return AccessoryDriver(pincode=b'123-45-678', address='127.0.0.1') + + +@pytest.fixture +def events(hass): + """Yield caught homekit_changed events.""" + events = [] + hass.bus.async_listen( + EVENT_HOMEKIT_CHANGED, + ha_callback(lambda e: events.append(e))) + yield events diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index edb1c7175f8..15ab6d7413e 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -10,14 +10,17 @@ import pytest from homeassistant.components.homekit.accessories import ( debounce, HomeAccessory, HomeBridge, HomeDriver) from homeassistant.components.homekit.const import ( + ATTR_DISPLAY_NAME, ATTR_VALUE, BRIDGE_MODEL, BRIDGE_NAME, BRIDGE_SERIAL_NUMBER, CHAR_FIRMWARE_REVISION, CHAR_MANUFACTURER, CHAR_MODEL, CHAR_NAME, CHAR_SERIAL_NUMBER, MANUFACTURER, SERV_ACCESSORY_INFO) from homeassistant.const import ( - __version__, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, ATTR_NOW, - EVENT_TIME_CHANGED) + __version__, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, + ATTR_SERVICE, ATTR_NOW, EVENT_TIME_CHANGED) import homeassistant.util.dt as dt_util +from tests.common import async_mock_service + async def test_debounce(hass): """Test add_timeout decorator function.""" @@ -146,6 +149,37 @@ async def test_battery_service(hass, hk_driver): assert acc._char_charging.value == 0 +async def test_call_service(hass, hk_driver, events): + """Test call_service method.""" + entity_id = 'homekit.accessory' + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + + acc = HomeAccessory(hass, hk_driver, 'Home Accessory', entity_id, 2, None) + call_service = async_mock_service(hass, 'cover', 'open_cover') + + test_domain = 'cover' + test_service = 'open_cover' + test_value = 'value' + + await acc.async_call_service( + test_domain, test_service, {ATTR_ENTITY_ID: entity_id}, test_value) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data == { + ATTR_ENTITY_ID: acc.entity_id, + ATTR_DISPLAY_NAME: acc.display_name, + ATTR_SERVICE: test_service, + ATTR_VALUE: test_value + } + + assert len(call_service) == 1 + assert call_service[0].domain == test_domain + assert call_service[0].service == test_service + assert call_service[0].data == {ATTR_ENTITY_ID: entity_id} + + def test_home_bridge(hk_driver): """Test HomeBridge class.""" bridge = HomeBridge('hass', hk_driver, BRIDGE_NAME) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index d5552cce82c..7d303c38e93 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -69,6 +69,7 @@ def test_customize_options(config, name): ('Thermostat', 'climate.test', 'auto', {ATTR_SUPPORTED_FEATURES: climate.SUPPORT_TARGET_TEMPERATURE_LOW | climate.SUPPORT_TARGET_TEMPERATURE_HIGH}, {}), + ('WaterHeater', 'water_heater.test', 'auto', {}, {}), ]) def test_types(type_name, entity_id, state, attrs, config): """Test if types are associated correctly.""" diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 04ed5df5702..c32abaef0dd 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -5,6 +5,7 @@ import pytest from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN, SUPPORT_STOP) +from homeassistant.components.homekit.const import ATTR_VALUE from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_CLOSED, STATE_OPEN, STATE_UNAVAILABLE, STATE_UNKNOWN) @@ -28,7 +29,7 @@ def cls(): patcher.stop() -async def test_garage_door_open_close(hass, hk_driver, cls): +async def test_garage_door_open_close(hass, hk_driver, cls, events): """Test if accessory and HA are updated accordingly.""" entity_id = 'cover.garage_door' @@ -73,6 +74,8 @@ async def test_garage_door_open_close(hass, hk_driver, cls): assert call_close_cover[0].data[ATTR_ENTITY_ID] == entity_id assert acc.char_current_state.value == 2 assert acc.char_target_state.value == 1 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None hass.states.async_set(entity_id, STATE_CLOSED) await hass.async_block_till_done() @@ -83,9 +86,11 @@ async def test_garage_door_open_close(hass, hk_driver, cls): assert call_open_cover[0].data[ATTR_ENTITY_ID] == entity_id assert acc.char_current_state.value == 3 assert acc.char_target_state.value == 0 + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is None -async def test_window_set_cover_position(hass, hk_driver, cls): +async def test_window_set_cover_position(hass, hk_driver, cls, events): """Test if accessory and HA are updated accordingly.""" entity_id = 'cover.window' @@ -123,6 +128,8 @@ async def test_window_set_cover_position(hass, hk_driver, cls): assert call_set_cover_position[0].data[ATTR_POSITION] == 25 assert acc.char_current_position.value == 50 assert acc.char_target_position.value == 25 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == 25 await hass.async_add_job(acc.char_target_position.client_update_value, 75) await hass.async_block_till_done() @@ -131,9 +138,11 @@ async def test_window_set_cover_position(hass, hk_driver, cls): assert call_set_cover_position[1].data[ATTR_POSITION] == 75 assert acc.char_current_position.value == 50 assert acc.char_target_position.value == 75 + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] == 75 -async def test_window_open_close(hass, hk_driver, cls): +async def test_window_open_close(hass, hk_driver, cls, events): """Test if accessory and HA are updated accordingly.""" entity_id = 'cover.window' @@ -178,6 +187,8 @@ async def test_window_open_close(hass, hk_driver, cls): assert acc.char_current_position.value == 0 assert acc.char_target_position.value == 0 assert acc.char_position_state.value == 2 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None await hass.async_add_job(acc.char_target_position.client_update_value, 90) await hass.async_block_till_done() @@ -186,6 +197,8 @@ async def test_window_open_close(hass, hk_driver, cls): assert acc.char_current_position.value == 100 assert acc.char_target_position.value == 100 assert acc.char_position_state.value == 2 + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is None await hass.async_add_job(acc.char_target_position.client_update_value, 55) await hass.async_block_till_done() @@ -194,9 +207,11 @@ async def test_window_open_close(hass, hk_driver, cls): assert acc.char_current_position.value == 100 assert acc.char_target_position.value == 100 assert acc.char_position_state.value == 2 + assert len(events) == 3 + assert events[-1].data[ATTR_VALUE] is None -async def test_window_open_close_stop(hass, hk_driver, cls): +async def test_window_open_close_stop(hass, hk_driver, cls, events): """Test if accessory and HA are updated accordingly.""" entity_id = 'cover.window' @@ -217,6 +232,8 @@ async def test_window_open_close_stop(hass, hk_driver, cls): assert acc.char_current_position.value == 0 assert acc.char_target_position.value == 0 assert acc.char_position_state.value == 2 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None await hass.async_add_job(acc.char_target_position.client_update_value, 90) await hass.async_block_till_done() @@ -225,6 +242,8 @@ async def test_window_open_close_stop(hass, hk_driver, cls): assert acc.char_current_position.value == 100 assert acc.char_target_position.value == 100 assert acc.char_position_state.value == 2 + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is None await hass.async_add_job(acc.char_target_position.client_update_value, 55) await hass.async_block_till_done() @@ -233,3 +252,5 @@ async def test_window_open_close_stop(hass, hk_driver, cls): assert acc.char_current_position.value == 50 assert acc.char_target_position.value == 50 assert acc.char_position_state.value == 2 + assert len(events) == 3 + assert events[-1].data[ATTR_VALUE] is None diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index 87a481ff06f..27b6cec0790 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -6,6 +6,7 @@ import pytest from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, DIRECTION_FORWARD, DIRECTION_REVERSE, DOMAIN, SUPPORT_DIRECTION, SUPPORT_OSCILLATE) +from homeassistant.components.homekit.const import ATTR_VALUE from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_ON, STATE_OFF, STATE_UNKNOWN) @@ -26,7 +27,7 @@ def cls(): patcher.stop() -async def test_fan_basic(hass, hk_driver, cls): +async def test_fan_basic(hass, hk_driver, cls, events): """Test fan with char state.""" entity_id = 'fan.demo' @@ -62,6 +63,8 @@ async def test_fan_basic(hass, hk_driver, cls): await hass.async_block_till_done() assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None hass.states.async_set(entity_id, STATE_ON) await hass.async_block_till_done() @@ -70,9 +73,11 @@ async def test_fan_basic(hass, hk_driver, cls): await hass.async_block_till_done() assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is None -async def test_fan_direction(hass, hk_driver, cls): +async def test_fan_direction(hass, hk_driver, cls, events): """Test fan with direction.""" entity_id = 'fan.demo' @@ -101,15 +106,19 @@ async def test_fan_direction(hass, hk_driver, cls): assert call_set_direction[0] assert call_set_direction[0].data[ATTR_ENTITY_ID] == entity_id assert call_set_direction[0].data[ATTR_DIRECTION] == DIRECTION_FORWARD + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == DIRECTION_FORWARD await hass.async_add_job(acc.char_direction.client_update_value, 1) await hass.async_block_till_done() assert call_set_direction[1] assert call_set_direction[1].data[ATTR_ENTITY_ID] == entity_id assert call_set_direction[1].data[ATTR_DIRECTION] == DIRECTION_REVERSE + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] == DIRECTION_REVERSE -async def test_fan_oscillate(hass, hk_driver, cls): +async def test_fan_oscillate(hass, hk_driver, cls, events): """Test fan with oscillate.""" entity_id = 'fan.demo' @@ -136,9 +145,13 @@ async def test_fan_oscillate(hass, hk_driver, cls): assert call_oscillate[0] assert call_oscillate[0].data[ATTR_ENTITY_ID] == entity_id assert call_oscillate[0].data[ATTR_OSCILLATING] is False + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is False await hass.async_add_job(acc.char_swing.client_update_value, 1) await hass.async_block_till_done() assert call_oscillate[1] assert call_oscillate[1].data[ATTR_ENTITY_ID] == entity_id assert call_oscillate[1].data[ATTR_OSCILLATING] is True + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is True diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index aab6274f484..c540952017b 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -3,6 +3,7 @@ from collections import namedtuple import pytest +from homeassistant.components.homekit.const import ATTR_VALUE from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, ATTR_HS_COLOR, DOMAIN, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR) @@ -26,7 +27,7 @@ def cls(): patcher.stop() -async def test_light_basic(hass, hk_driver, cls): +async def test_light_basic(hass, hk_driver, cls, events): """Test light with char state.""" entity_id = 'light.demo' @@ -62,6 +63,8 @@ async def test_light_basic(hass, hk_driver, cls): await hass.async_block_till_done() assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None hass.states.async_set(entity_id, STATE_ON) await hass.async_block_till_done() @@ -70,9 +73,11 @@ async def test_light_basic(hass, hk_driver, cls): await hass.async_block_till_done() assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is None -async def test_light_brightness(hass, hk_driver, cls): +async def test_light_brightness(hass, hk_driver, cls, events): """Test light with brightness.""" entity_id = 'light.demo' @@ -101,6 +106,8 @@ async def test_light_brightness(hass, hk_driver, cls): assert call_turn_on[0] assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == 'brightness at 20%' await hass.async_add_job(acc.char_on.client_update_value, 1) await hass.async_add_job(acc.char_brightness.client_update_value, 40) @@ -108,15 +115,19 @@ async def test_light_brightness(hass, hk_driver, cls): assert call_turn_on[1] assert call_turn_on[1].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[1].data[ATTR_BRIGHTNESS_PCT] == 40 + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] == 'brightness at 40%' await hass.async_add_job(acc.char_on.client_update_value, 1) await hass.async_add_job(acc.char_brightness.client_update_value, 0) await hass.async_block_till_done() assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 3 + assert events[-1].data[ATTR_VALUE] is None -async def test_light_color_temperature(hass, hk_driver, cls): +async def test_light_color_temperature(hass, hk_driver, cls, events): """Test light with color temperature.""" entity_id = 'light.demo' @@ -141,9 +152,11 @@ async def test_light_color_temperature(hass, hk_driver, cls): assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[0].data[ATTR_COLOR_TEMP] == 250 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == 'color temperature at 250' -async def test_light_rgb_color(hass, hk_driver, cls): +async def test_light_rgb_color(hass, hk_driver, cls, events): """Test light with rgb_color.""" entity_id = 'light.demo' @@ -170,3 +183,5 @@ async def test_light_rgb_color(hass, hk_driver, cls): assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[0].data[ATTR_HS_COLOR] == (145, 75) + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == 'set color at (145, 75)' diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py index 8f18a591019..8132099bd3e 100644 --- a/tests/components/homekit/test_type_locks.py +++ b/tests/components/homekit/test_type_locks.py @@ -1,6 +1,7 @@ """Test different accessory types: Locks.""" import pytest +from homeassistant.components.homekit.const import ATTR_VALUE from homeassistant.components.homekit.type_locks import Lock from homeassistant.components.lock import DOMAIN from homeassistant.const import ( @@ -9,7 +10,7 @@ from homeassistant.const import ( from tests.common import async_mock_service -async def test_lock_unlock(hass, hk_driver): +async def test_lock_unlock(hass, hk_driver, events): """Test if accessory and HA are updated accordingly.""" code = '1234' config = {ATTR_CODE: code} @@ -56,6 +57,8 @@ async def test_lock_unlock(hass, hk_driver): assert call_lock[0].data[ATTR_ENTITY_ID] == entity_id assert call_lock[0].data[ATTR_CODE] == code assert acc.char_target_state.value == 1 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None await hass.async_add_job(acc.char_target_state.client_update_value, 0) await hass.async_block_till_done() @@ -63,10 +66,12 @@ async def test_lock_unlock(hass, hk_driver): assert call_unlock[0].data[ATTR_ENTITY_ID] == entity_id assert call_unlock[0].data[ATTR_CODE] == code assert acc.char_target_state.value == 0 + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is None @pytest.mark.parametrize('config', [{}, {ATTR_CODE: None}]) -async def test_no_code(hass, hk_driver, config): +async def test_no_code(hass, hk_driver, config, events): """Test accessory if lock doesn't require a code.""" entity_id = 'lock.kitchen_door' @@ -77,9 +82,12 @@ async def test_no_code(hass, hk_driver, config): # Set from HomeKit call_lock = async_mock_service(hass, DOMAIN, 'lock') + acc.char_target_state.value = 0 await hass.async_add_job(acc.char_target_state.client_update_value, 1) await hass.async_block_till_done() assert call_lock assert call_lock[0].data[ATTR_ENTITY_ID] == entity_id assert ATTR_CODE not in call_lock[0].data assert acc.char_target_state.value == 1 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index 681cbba7252..745e4c162bc 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -1,8 +1,8 @@ """Test different accessory types: Media Players.""" from homeassistant.components.homekit.const import ( - CONF_FEATURE_LIST, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, - FEATURE_TOGGLE_MUTE) + ATTR_VALUE, CONF_FEATURE_LIST, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, + FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE) from homeassistant.components.homekit.type_media_players import MediaPlayer from homeassistant.components.media_player import ( ATTR_MEDIA_VOLUME_MUTED, DOMAIN) @@ -13,7 +13,7 @@ from homeassistant.const import ( from tests.common import async_mock_service -async def test_media_player_set_state(hass, hk_driver): +async def test_media_player_set_state(hass, hk_driver, events): """Test if accessory and HA are updated accordingly.""" config = {CONF_FEATURE_LIST: { FEATURE_ON_OFF: None, FEATURE_PLAY_PAUSE: None, @@ -29,32 +29,32 @@ async def test_media_player_set_state(hass, hk_driver): assert acc.aid == 2 assert acc.category == 8 # Switch - assert acc.chars[FEATURE_ON_OFF].value == 0 - assert acc.chars[FEATURE_PLAY_PAUSE].value == 0 - assert acc.chars[FEATURE_PLAY_STOP].value == 0 - assert acc.chars[FEATURE_TOGGLE_MUTE].value == 0 + assert acc.chars[FEATURE_ON_OFF].value is False + assert acc.chars[FEATURE_PLAY_PAUSE].value is False + assert acc.chars[FEATURE_PLAY_STOP].value is False + assert acc.chars[FEATURE_TOGGLE_MUTE].value is False hass.states.async_set(entity_id, STATE_ON, {ATTR_MEDIA_VOLUME_MUTED: True}) await hass.async_block_till_done() - assert acc.chars[FEATURE_ON_OFF].value == 1 - assert acc.chars[FEATURE_TOGGLE_MUTE].value == 1 + assert acc.chars[FEATURE_ON_OFF].value is True + assert acc.chars[FEATURE_TOGGLE_MUTE].value is True hass.states.async_set(entity_id, STATE_OFF) await hass.async_block_till_done() - assert acc.chars[FEATURE_ON_OFF].value == 0 + assert acc.chars[FEATURE_ON_OFF].value is False hass.states.async_set(entity_id, STATE_PLAYING) await hass.async_block_till_done() - assert acc.chars[FEATURE_PLAY_PAUSE].value == 1 - assert acc.chars[FEATURE_PLAY_STOP].value == 1 + assert acc.chars[FEATURE_PLAY_PAUSE].value is True + assert acc.chars[FEATURE_PLAY_STOP].value is True hass.states.async_set(entity_id, STATE_PAUSED) await hass.async_block_till_done() - assert acc.chars[FEATURE_PLAY_PAUSE].value == 0 + assert acc.chars[FEATURE_PLAY_PAUSE].value is False hass.states.async_set(entity_id, STATE_IDLE) await hass.async_block_till_done() - assert acc.chars[FEATURE_PLAY_STOP].value == 0 + assert acc.chars[FEATURE_PLAY_STOP].value is False # Set from HomeKit call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on') @@ -64,41 +64,54 @@ async def test_media_player_set_state(hass, hk_driver): call_media_stop = async_mock_service(hass, DOMAIN, 'media_stop') call_toggle_mute = async_mock_service(hass, DOMAIN, 'volume_mute') + acc.chars[FEATURE_ON_OFF].value = False await hass.async_add_job(acc.chars[FEATURE_ON_OFF] .client_update_value, True) await hass.async_block_till_done() assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None await hass.async_add_job(acc.chars[FEATURE_ON_OFF] .client_update_value, False) await hass.async_block_till_done() assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is None await hass.async_add_job(acc.chars[FEATURE_PLAY_PAUSE] .client_update_value, True) await hass.async_block_till_done() assert call_media_play assert call_media_play[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 3 + assert events[-1].data[ATTR_VALUE] is None await hass.async_add_job(acc.chars[FEATURE_PLAY_PAUSE] .client_update_value, False) await hass.async_block_till_done() assert call_media_pause assert call_media_pause[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 4 + assert events[-1].data[ATTR_VALUE] is None await hass.async_add_job(acc.chars[FEATURE_PLAY_STOP] .client_update_value, True) await hass.async_block_till_done() assert call_media_play assert call_media_play[1].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 5 + assert events[-1].data[ATTR_VALUE] is None await hass.async_add_job(acc.chars[FEATURE_PLAY_STOP] .client_update_value, False) await hass.async_block_till_done() assert call_media_stop assert call_media_stop[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 6 + assert events[-1].data[ATTR_VALUE] is None await hass.async_add_job(acc.chars[FEATURE_TOGGLE_MUTE] .client_update_value, True) @@ -106,6 +119,8 @@ async def test_media_player_set_state(hass, hk_driver): assert call_toggle_mute assert call_toggle_mute[0].data[ATTR_ENTITY_ID] == entity_id assert call_toggle_mute[0].data[ATTR_MEDIA_VOLUME_MUTED] is True + assert len(events) == 7 + assert events[-1].data[ATTR_VALUE] is None await hass.async_add_job(acc.chars[FEATURE_TOGGLE_MUTE] .client_update_value, False) @@ -113,3 +128,5 @@ async def test_media_player_set_state(hass, hk_driver): assert call_toggle_mute assert call_toggle_mute[1].data[ATTR_ENTITY_ID] == entity_id assert call_toggle_mute[1].data[ATTR_MEDIA_VOLUME_MUTED] is False + assert len(events) == 8 + assert events[-1].data[ATTR_VALUE] is None diff --git a/tests/components/homekit/test_type_security_systems.py b/tests/components/homekit/test_type_security_systems.py index 3ddce0f36eb..3753a1aa433 100644 --- a/tests/components/homekit/test_type_security_systems.py +++ b/tests/components/homekit/test_type_security_systems.py @@ -2,6 +2,7 @@ import pytest from homeassistant.components.alarm_control_panel import DOMAIN +from homeassistant.components.homekit.const import ATTR_VALUE from homeassistant.components.homekit.type_security_systems import \ SecuritySystem from homeassistant.const import ( @@ -12,7 +13,7 @@ from homeassistant.const import ( from tests.common import async_mock_service -async def test_switch_set_state(hass, hk_driver): +async def test_switch_set_state(hass, hk_driver, events): """Test if accessory and HA are updated accordingly.""" code = '1234' config = {ATTR_CODE: code} @@ -72,6 +73,8 @@ async def test_switch_set_state(hass, hk_driver): assert call_arm_home[0].data[ATTR_ENTITY_ID] == entity_id assert call_arm_home[0].data[ATTR_CODE] == code assert acc.char_target_state.value == 0 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None await hass.async_add_job(acc.char_target_state.client_update_value, 1) await hass.async_block_till_done() @@ -79,6 +82,8 @@ async def test_switch_set_state(hass, hk_driver): assert call_arm_away[0].data[ATTR_ENTITY_ID] == entity_id assert call_arm_away[0].data[ATTR_CODE] == code assert acc.char_target_state.value == 1 + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is None await hass.async_add_job(acc.char_target_state.client_update_value, 2) await hass.async_block_till_done() @@ -86,6 +91,8 @@ async def test_switch_set_state(hass, hk_driver): assert call_arm_night[0].data[ATTR_ENTITY_ID] == entity_id assert call_arm_night[0].data[ATTR_CODE] == code assert acc.char_target_state.value == 2 + assert len(events) == 3 + assert events[-1].data[ATTR_VALUE] is None await hass.async_add_job(acc.char_target_state.client_update_value, 3) await hass.async_block_till_done() @@ -93,10 +100,12 @@ async def test_switch_set_state(hass, hk_driver): assert call_disarm[0].data[ATTR_ENTITY_ID] == entity_id assert call_disarm[0].data[ATTR_CODE] == code assert acc.char_target_state.value == 3 + assert len(events) == 4 + assert events[-1].data[ATTR_VALUE] is None @pytest.mark.parametrize('config', [{}, {ATTR_CODE: None}]) -async def test_no_alarm_code(hass, hk_driver, config): +async def test_no_alarm_code(hass, hk_driver, config, events): """Test accessory if security_system doesn't require an alarm_code.""" entity_id = 'alarm_control_panel.test' @@ -114,3 +123,5 @@ async def test_no_alarm_code(hass, hk_driver, config): assert call_arm_home[0].data[ATTR_ENTITY_ID] == entity_id assert ATTR_CODE not in call_arm_home[0].data assert acc.char_target_state.value == 0 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index bc44a93884a..d170647d492 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -2,7 +2,7 @@ import pytest from homeassistant.components.homekit.const import ( - TYPE_FAUCET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_VALVE) + ATTR_VALUE, TYPE_FAUCET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_VALVE) from homeassistant.components.homekit.type_switches import ( Outlet, Switch, Valve) from homeassistant.const import ATTR_ENTITY_ID, CONF_TYPE, STATE_OFF, STATE_ON @@ -11,7 +11,7 @@ from homeassistant.core import split_entity_id from tests.common import async_mock_service -async def test_outlet_set_state(hass, hk_driver): +async def test_outlet_set_state(hass, hk_driver, events): """Test if Outlet accessory and HA are updated accordingly.""" entity_id = 'switch.outlet_test' @@ -43,11 +43,15 @@ async def test_outlet_set_state(hass, hk_driver): await hass.async_block_till_done() assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None await hass.async_add_job(acc.char_on.client_update_value, False) await hass.async_block_till_done() assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is None @pytest.mark.parametrize('entity_id', [ @@ -57,7 +61,7 @@ async def test_outlet_set_state(hass, hk_driver): 'script.test', 'switch.test', ]) -async def test_switch_set_state(hass, hk_driver, entity_id): +async def test_switch_set_state(hass, hk_driver, entity_id, events): """Test if accessory and HA are updated accordingly.""" domain = split_entity_id(entity_id)[0] @@ -88,14 +92,18 @@ async def test_switch_set_state(hass, hk_driver, entity_id): await hass.async_block_till_done() assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None await hass.async_add_job(acc.char_on.client_update_value, False) await hass.async_block_till_done() assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is None -async def test_valve_set_state(hass, hk_driver): +async def test_valve_set_state(hass, hk_driver, events): """Test if Valve accessory and HA are updated accordingly.""" entity_id = 'switch.valve_test' @@ -154,9 +162,13 @@ async def test_valve_set_state(hass, hk_driver): assert acc.char_in_use.value is True assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None await hass.async_add_job(acc.char_active.client_update_value, False) await hass.async_block_till_done() assert acc.char_in_use.value is False assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is None diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index 687a9e9513c..795cb5db7d2 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -5,15 +5,18 @@ from unittest.mock import patch import pytest from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE, ATTR_MAX_TEMP, ATTR_MIN_TEMP, ATTR_TEMPERATURE, + ATTR_CURRENT_TEMPERATURE, ATTR_MAX_TEMP, ATTR_MIN_TEMP, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH, ATTR_OPERATION_MODE, - ATTR_OPERATION_LIST, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN, - STATE_AUTO, STATE_COOL, STATE_HEAT) + ATTR_OPERATION_LIST, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, + DOMAIN as DOMAIN_CLIMATE, STATE_AUTO, STATE_COOL, STATE_HEAT) from homeassistant.components.homekit.const import ( + ATTR_VALUE, DEFAULT_MAX_TEMP_WATER_HEATER, DEFAULT_MIN_TEMP_WATER_HEATER, PROP_MAX_VALUE, PROP_MIN_VALUE) +from homeassistant.components.water_heater import ( + DOMAIN as DOMAIN_WATER_HEATER) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_TEMPERATURE_UNIT, STATE_OFF, - TEMP_FAHRENHEIT) + ATTR_ENTITY_ID, ATTR_TEMPERATURE, ATTR_SUPPORTED_FEATURES, + CONF_TEMPERATURE_UNIT, STATE_OFF, TEMP_FAHRENHEIT) from tests.common import async_mock_service from tests.components.homekit.common import patch_debounce @@ -25,13 +28,14 @@ def cls(): patcher = patch_debounce() patcher.start() _import = __import__('homeassistant.components.homekit.type_thermostats', - fromlist=['Thermostat']) - patcher_tuple = namedtuple('Cls', ['thermostat']) - yield patcher_tuple(thermostat=_import.Thermostat) + fromlist=['Thermostat', 'WaterHeater']) + patcher_tuple = namedtuple('Cls', ['thermostat', 'water_heater']) + yield patcher_tuple(thermostat=_import.Thermostat, + water_heater=_import.WaterHeater) patcher.stop() -async def test_default_thermostat(hass, hk_driver, cls): +async def test_thermostat(hass, hk_driver, cls, events): """Test if accessory and HA are updated accordingly.""" entity_id = 'climate.test' @@ -147,8 +151,9 @@ async def test_default_thermostat(hass, hk_driver, cls): assert acc.char_display_units.value == 0 # Set from HomeKit - call_set_temperature = async_mock_service(hass, DOMAIN, 'set_temperature') - call_set_operation_mode = async_mock_service(hass, DOMAIN, + call_set_temperature = async_mock_service(hass, DOMAIN_CLIMATE, + 'set_temperature') + call_set_operation_mode = async_mock_service(hass, DOMAIN_CLIMATE, 'set_operation_mode') await hass.async_add_job(acc.char_target_temp.client_update_value, 19.0) @@ -157,6 +162,8 @@ async def test_default_thermostat(hass, hk_driver, cls): assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id assert call_set_temperature[0].data[ATTR_TEMPERATURE] == 19.0 assert acc.char_target_temp.value == 19.0 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == '19.0°C' await hass.async_add_job(acc.char_target_heat_cool.client_update_value, 1) await hass.async_block_till_done() @@ -164,9 +171,11 @@ async def test_default_thermostat(hass, hk_driver, cls): assert call_set_operation_mode[0].data[ATTR_ENTITY_ID] == entity_id assert call_set_operation_mode[0].data[ATTR_OPERATION_MODE] == STATE_HEAT assert acc.char_target_heat_cool.value == 1 + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] == STATE_HEAT -async def test_auto_thermostat(hass, hk_driver, cls): +async def test_thermostat_auto(hass, hk_driver, cls, events): """Test if accessory and HA are updated accordingly.""" entity_id = 'climate.test' @@ -229,7 +238,8 @@ async def test_auto_thermostat(hass, hk_driver, cls): assert acc.char_display_units.value == 0 # Set from HomeKit - call_set_temperature = async_mock_service(hass, DOMAIN, 'set_temperature') + call_set_temperature = async_mock_service(hass, DOMAIN_CLIMATE, + 'set_temperature') await hass.async_add_job( acc.char_heating_thresh_temp.client_update_value, 20.0) @@ -238,6 +248,8 @@ async def test_auto_thermostat(hass, hk_driver, cls): assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 20.0 assert acc.char_heating_thresh_temp.value == 20.0 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == 'heating threshold 20.0°C' await hass.async_add_job( acc.char_cooling_thresh_temp.client_update_value, 25.0) @@ -246,9 +258,11 @@ async def test_auto_thermostat(hass, hk_driver, cls): assert call_set_temperature[1].data[ATTR_ENTITY_ID] == entity_id assert call_set_temperature[1].data[ATTR_TARGET_TEMP_HIGH] == 25.0 assert acc.char_cooling_thresh_temp.value == 25.0 + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] == 'cooling threshold 25.0°C' -async def test_power_state(hass, hk_driver, cls): +async def test_thermostat_power_state(hass, hk_driver, cls, events): """Test if accessory and HA are updated accordingly.""" entity_id = 'climate.test' @@ -284,9 +298,9 @@ async def test_power_state(hass, hk_driver, cls): assert acc.char_target_heat_cool.value == 0 # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on') - call_turn_off = async_mock_service(hass, DOMAIN, 'turn_off') - call_set_operation_mode = async_mock_service(hass, DOMAIN, + call_turn_on = async_mock_service(hass, DOMAIN_CLIMATE, 'turn_on') + call_turn_off = async_mock_service(hass, DOMAIN_CLIMATE, 'turn_off') + call_set_operation_mode = async_mock_service(hass, DOMAIN_CLIMATE, 'set_operation_mode') await hass.async_add_job(acc.char_target_heat_cool.client_update_value, 1) @@ -297,15 +311,19 @@ async def test_power_state(hass, hk_driver, cls): assert call_set_operation_mode[0].data[ATTR_ENTITY_ID] == entity_id assert call_set_operation_mode[0].data[ATTR_OPERATION_MODE] == STATE_HEAT assert acc.char_target_heat_cool.value == 1 + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] == STATE_HEAT await hass.async_add_job(acc.char_target_heat_cool.client_update_value, 0) await hass.async_block_till_done() assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id assert acc.char_target_heat_cool.value == 0 + assert len(events) == 3 + assert events[-1].data[ATTR_VALUE] is None -async def test_thermostat_fahrenheit(hass, hk_driver, cls): +async def test_thermostat_fahrenheit(hass, hk_driver, cls, events): """Test if accessory and HA are updated accordingly.""" entity_id = 'climate.test' @@ -332,7 +350,8 @@ async def test_thermostat_fahrenheit(hass, hk_driver, cls): assert acc.char_display_units.value == 1 # Set from HomeKit - call_set_temperature = async_mock_service(hass, DOMAIN, 'set_temperature') + call_set_temperature = async_mock_service(hass, DOMAIN_CLIMATE, + 'set_temperature') await hass.async_add_job( acc.char_cooling_thresh_temp.client_update_value, 23) @@ -341,6 +360,8 @@ async def test_thermostat_fahrenheit(hass, hk_driver, cls): assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 73.4 assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 68 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == 'cooling threshold 73.4°F' await hass.async_add_job( acc.char_heating_thresh_temp.client_update_value, 22) @@ -349,15 +370,19 @@ async def test_thermostat_fahrenheit(hass, hk_driver, cls): assert call_set_temperature[1].data[ATTR_ENTITY_ID] == entity_id assert call_set_temperature[1].data[ATTR_TARGET_TEMP_HIGH] == 73.4 assert call_set_temperature[1].data[ATTR_TARGET_TEMP_LOW] == 71.6 + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] == 'heating threshold 71.6°F' await hass.async_add_job(acc.char_target_temp.client_update_value, 24.0) await hass.async_block_till_done() assert call_set_temperature[2] assert call_set_temperature[2].data[ATTR_ENTITY_ID] == entity_id assert call_set_temperature[2].data[ATTR_TEMPERATURE] == 75.2 + assert len(events) == 3 + assert events[-1].data[ATTR_VALUE] == '75.2°F' -async def test_get_temperature_range(hass, hk_driver, cls): +async def test_thermostat_get_temperature_range(hass, hk_driver, cls): """Test if temperature range is evaluated correctly.""" entity_id = 'climate.test' @@ -375,3 +400,123 @@ async def test_get_temperature_range(hass, hk_driver, cls): {ATTR_MIN_TEMP: 60, ATTR_MAX_TEMP: 70}) await hass.async_block_till_done() assert acc.get_temperature_range() == (15.6, 21.1) + + +async def test_water_heater(hass, hk_driver, cls, events): + """Test if accessory and HA are updated accordingly.""" + entity_id = 'water_heater.test' + + hass.states.async_set(entity_id, STATE_HEAT) + await hass.async_block_till_done() + acc = cls.water_heater(hass, hk_driver, 'WaterHeater', entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 9 # Thermostat + + assert acc.char_current_heat_cool.value == 1 # Heat + assert acc.char_target_heat_cool.value == 1 # Heat + assert acc.char_current_temp.value == 50.0 + assert acc.char_target_temp.value == 50.0 + assert acc.char_display_units.value == 0 + + assert acc.char_target_temp.properties[PROP_MAX_VALUE] == \ + DEFAULT_MAX_TEMP_WATER_HEATER + assert acc.char_target_temp.properties[PROP_MIN_VALUE] == \ + DEFAULT_MIN_TEMP_WATER_HEATER + + hass.states.async_set(entity_id, STATE_HEAT, + {ATTR_OPERATION_MODE: STATE_HEAT, + ATTR_TEMPERATURE: 56.0}) + await hass.async_block_till_done() + assert acc.char_target_temp.value == 56.0 + assert acc.char_current_temp.value == 56.0 + assert acc.char_target_heat_cool.value == 1 + assert acc.char_current_heat_cool.value == 1 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO}) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == 1 + assert acc.char_current_heat_cool.value == 1 + + # Set from HomeKit + call_set_temperature = async_mock_service(hass, DOMAIN_WATER_HEATER, + 'set_temperature') + + await hass.async_add_job(acc.char_target_temp.client_update_value, 52.0) + await hass.async_block_till_done() + assert call_set_temperature + assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == 52.0 + assert acc.char_target_temp.value == 52.0 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == '52.0°C' + + await hass.async_add_job(acc.char_target_heat_cool.client_update_value, 0) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == 1 + + await hass.async_add_job(acc.char_target_heat_cool.client_update_value, 2) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == 1 + + await hass.async_add_job(acc.char_target_heat_cool.client_update_value, 3) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == 1 + + +async def test_water_heater_fahrenheit(hass, hk_driver, cls, events): + """Test if accessory and HA are update accordingly.""" + entity_id = 'water_heater.test' + + hass.states.async_set(entity_id, STATE_HEAT) + await hass.async_block_till_done() + with patch.object(hass.config.units, CONF_TEMPERATURE_UNIT, + new=TEMP_FAHRENHEIT): + acc = cls.water_heater(hass, hk_driver, 'WaterHeater', + entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + + hass.states.async_set(entity_id, STATE_HEAT, + {ATTR_TEMPERATURE: 131}) + await hass.async_block_till_done() + assert acc.char_target_temp.value == 55.0 + assert acc.char_current_temp.value == 55.0 + assert acc.char_display_units.value == 1 + + # Set from HomeKit + call_set_temperature = async_mock_service(hass, DOMAIN_WATER_HEATER, + 'set_temperature') + + await hass.async_add_job(acc.char_target_temp.client_update_value, 60) + await hass.async_block_till_done() + assert call_set_temperature + assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == 140.0 + assert acc.char_target_temp.value == 60.0 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == '140.0°F' + + +async def test_water_heater_get_temperature_range(hass, hk_driver, cls): + """Test if temperature range is evaluated correctly.""" + entity_id = 'water_heater.test' + + hass.states.async_set(entity_id, STATE_HEAT) + await hass.async_block_till_done() + acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None) + + hass.states.async_set(entity_id, STATE_HEAT, + {ATTR_MIN_TEMP: 20, ATTR_MAX_TEMP: 25}) + await hass.async_block_till_done() + assert acc.get_temperature_range() == (20, 25) + + acc._unit = TEMP_FAHRENHEIT + hass.states.async_set(entity_id, STATE_OFF, + {ATTR_MIN_TEMP: 60, ATTR_MAX_TEMP: 70}) + await hass.async_block_till_done() + assert acc.get_temperature_range() == (15.6, 21.1) diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index 118cdb3c995..5a768820e18 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -137,6 +137,21 @@ light: payload_on: "on" payload_off: "off" +Configuration for HS Version with brightness: + +light: + platform: mqtt + name: "Office Light HS" + state_topic: "office/hs1/light/status" + command_topic: "office/hs1/light/switch" + brightness_state_topic: "office/hs1/brightness/status" + brightness_command_topic: "office/hs1/brightness/set" + hs_state_topic: "office/hs1/hs/status" + hs_command_topic: "office/hs1/hs/set" + qos: 0 + payload_on: "on" + payload_off: "off" + """ import unittest from unittest import mock @@ -180,7 +195,7 @@ class TestLightMQTT(unittest.TestCase): }) self.assertIsNone(self.hass.states.get('light.test')) - def test_no_color_brightness_color_temp_white_xy_if_no_topics(self): + def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics(self): """Test if there is no color and brightness if no topic.""" with assert_setup_component(1, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -197,6 +212,7 @@ class TestLightMQTT(unittest.TestCase): self.assertIsNone(state.attributes.get('rgb_color')) self.assertIsNone(state.attributes.get('brightness')) self.assertIsNone(state.attributes.get('color_temp')) + self.assertIsNone(state.attributes.get('hs_color')) self.assertIsNone(state.attributes.get('white_value')) self.assertIsNone(state.attributes.get('xy_color')) @@ -208,6 +224,7 @@ class TestLightMQTT(unittest.TestCase): self.assertIsNone(state.attributes.get('rgb_color')) self.assertIsNone(state.attributes.get('brightness')) self.assertIsNone(state.attributes.get('color_temp')) + self.assertIsNone(state.attributes.get('hs_color')) self.assertIsNone(state.attributes.get('white_value')) self.assertIsNone(state.attributes.get('xy_color')) @@ -226,6 +243,8 @@ class TestLightMQTT(unittest.TestCase): 'color_temp_command_topic': 'test_light_rgb/color_temp/set', 'effect_state_topic': 'test_light_rgb/effect/status', 'effect_command_topic': 'test_light_rgb/effect/set', + 'hs_state_topic': 'test_light_rgb/hs/status', + 'hs_command_topic': 'test_light_rgb/hs/set', 'white_value_state_topic': 'test_light_rgb/white_value/status', 'white_value_command_topic': 'test_light_rgb/white_value/set', 'xy_state_topic': 'test_light_rgb/xy/status', @@ -244,6 +263,7 @@ class TestLightMQTT(unittest.TestCase): self.assertIsNone(state.attributes.get('brightness')) self.assertIsNone(state.attributes.get('color_temp')) self.assertIsNone(state.attributes.get('effect')) + self.assertIsNone(state.attributes.get('hs_color')) self.assertIsNone(state.attributes.get('white_value')) self.assertIsNone(state.attributes.get('xy_color')) self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) @@ -257,6 +277,7 @@ class TestLightMQTT(unittest.TestCase): self.assertEqual(255, state.attributes.get('brightness')) self.assertEqual(150, state.attributes.get('color_temp')) self.assertEqual('none', state.attributes.get('effect')) + self.assertEqual((0, 0), state.attributes.get('hs_color')) self.assertEqual(255, state.attributes.get('white_value')) self.assertEqual((0.323, 0.329), state.attributes.get('xy_color')) @@ -309,6 +330,14 @@ class TestLightMQTT(unittest.TestCase): self.assertEqual((255, 255, 255), light_state.attributes.get('rgb_color')) + fire_mqtt_message(self.hass, 'test_light_rgb/hs/status', + '200,50') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.assertEqual((200, 50), + light_state.attributes.get('hs_color')) + fire_mqtt_message(self.hass, 'test_light_rgb/xy/status', '0.675,0.322') self.hass.block_till_done() @@ -364,6 +393,41 @@ class TestLightMQTT(unittest.TestCase): self.assertEqual(255, light_state.attributes['brightness']) + def test_brightness_from_rgb_controlling_scale(self): + """Test the brightness controlling scale.""" + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test_scale_rgb/status', + 'command_topic': 'test_scale_rgb/set', + 'rgb_state_topic': 'test_scale_rgb/rgb/status', + 'rgb_command_topic': 'test_scale_rgb/rgb/set', + 'qos': 0, + 'payload_on': 'on', + 'payload_off': 'off' + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertIsNone(state.attributes.get('brightness')) + self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) + + fire_mqtt_message(self.hass, 'test_scale_rgb/status', 'on') + fire_mqtt_message(self.hass, 'test_scale_rgb/rgb/status', '255,0,0') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(255, state.attributes.get('brightness')) + + fire_mqtt_message(self.hass, 'test_scale_rgb/rgb/status', '127,0,0') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(127, state.attributes.get('brightness')) + def test_white_value_controlling_scale(self): """Test the white_value controlling scale.""" with assert_setup_component(1, light.DOMAIN): @@ -412,7 +476,7 @@ class TestLightMQTT(unittest.TestCase): light_state.attributes['white_value']) def test_controlling_state_via_topic_with_templates(self): - """Test the setting og the state with a template.""" + """Test the setting of the state with a template.""" config = {light.DOMAIN: { 'platform': 'mqtt', 'name': 'test', @@ -422,11 +486,13 @@ class TestLightMQTT(unittest.TestCase): 'rgb_command_topic': 'test_light_rgb/rgb/set', 'color_temp_command_topic': 'test_light_rgb/color_temp/set', 'effect_command_topic': 'test_light_rgb/effect/set', + 'hs_command_topic': 'test_light_rgb/hs/set', 'white_value_command_topic': 'test_light_rgb/white_value/set', 'xy_command_topic': 'test_light_rgb/xy/set', 'brightness_state_topic': 'test_light_rgb/brightness/status', 'color_temp_state_topic': 'test_light_rgb/color_temp/status', 'effect_state_topic': 'test_light_rgb/effect/status', + 'hs_state_topic': 'test_light_rgb/hs/status', 'rgb_state_topic': 'test_light_rgb/rgb/status', 'white_value_state_topic': 'test_light_rgb/white_value/status', 'xy_state_topic': 'test_light_rgb/xy/status', @@ -434,6 +500,7 @@ class TestLightMQTT(unittest.TestCase): 'brightness_value_template': '{{ value_json.hello }}', 'color_temp_value_template': '{{ value_json.hello }}', 'effect_value_template': '{{ value_json.hello }}', + 'hs_value_template': '{{ value_json.hello | join(",") }}', 'rgb_value_template': '{{ value_json.hello | join(",") }}', 'white_value_template': '{{ value_json.hello }}', 'xy_value_template': '{{ value_json.hello | join(",") }}', @@ -459,17 +526,28 @@ class TestLightMQTT(unittest.TestCase): '{"hello": "rainbow"}') fire_mqtt_message(self.hass, 'test_light_rgb/white_value/status', '{"hello": "75"}') - fire_mqtt_message(self.hass, 'test_light_rgb/xy/status', - '{"hello": [0.123,0.123]}') self.hass.block_till_done() state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) self.assertEqual(50, state.attributes.get('brightness')) - self.assertEqual((0, 123, 255), state.attributes.get('rgb_color')) + self.assertEqual((84, 169, 255), state.attributes.get('rgb_color')) self.assertEqual(300, state.attributes.get('color_temp')) self.assertEqual('rainbow', state.attributes.get('effect')) self.assertEqual(75, state.attributes.get('white_value')) + + fire_mqtt_message(self.hass, 'test_light_rgb/hs/status', + '{"hello": [100,50]}') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual((100, 50), state.attributes.get('hs_color')) + + fire_mqtt_message(self.hass, 'test_light_rgb/xy/status', + '{"hello": [0.123,0.123]}') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') self.assertEqual((0.14, 0.131), state.attributes.get('xy_color')) def test_sending_mqtt_commands_and_optimistic(self): @@ -482,6 +560,7 @@ class TestLightMQTT(unittest.TestCase): 'rgb_command_topic': 'test_light_rgb/rgb/set', 'color_temp_command_topic': 'test_light_rgb/color_temp/set', 'effect_command_topic': 'test_light_rgb/effect/set', + 'hs_command_topic': 'test_light_rgb/hs/set', 'white_value_command_topic': 'test_light_rgb/white_value/set', 'xy_command_topic': 'test_light_rgb/xy/set', 'effect_list': ['colorloop', 'random'], @@ -529,6 +608,8 @@ class TestLightMQTT(unittest.TestCase): self.mock_publish.reset_mock() common.turn_on(self.hass, 'light.test', brightness=50, xy_color=[0.123, 0.123]) + common.turn_on(self.hass, 'light.test', + brightness=50, hs_color=[359, 78]) common.turn_on(self.hass, 'light.test', rgb_color=[255, 128, 0], white_value=80) self.hass.block_till_done() @@ -537,6 +618,7 @@ class TestLightMQTT(unittest.TestCase): mock.call('test_light_rgb/set', 'on', 2, False), mock.call('test_light_rgb/rgb/set', '255,128,0', 2, False), mock.call('test_light_rgb/brightness/set', 50, 2, False), + mock.call('test_light_rgb/hs/set', '359.0,78.0', 2, False), mock.call('test_light_rgb/white_value/set', 80, 2, False), mock.call('test_light_rgb/xy/set', '0.14,0.131', 2, False), ], any_order=True) @@ -545,6 +627,7 @@ class TestLightMQTT(unittest.TestCase): self.assertEqual(STATE_ON, state.state) self.assertEqual((255, 128, 0), state.attributes['rgb_color']) self.assertEqual(50, state.attributes['brightness']) + self.assertEqual((30.118, 100), state.attributes['hs_color']) self.assertEqual(80, state.attributes['white_value']) self.assertEqual((0.611, 0.375), state.attributes['xy_color']) @@ -652,6 +735,30 @@ class TestLightMQTT(unittest.TestCase): self.assertEqual(STATE_ON, state.state) self.assertEqual('none', state.attributes.get('effect')) + def test_show_hs_if_only_command_topic(self): + """Test the hs if only a command topic is present.""" + config = {light.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'hs_command_topic': 'test_light_rgb/hs/set', + 'command_topic': 'test_light_rgb/set', + 'state_topic': 'test_light_rgb/status', + }} + + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, config) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertIsNone(state.attributes.get('hs_color')) + + fire_mqtt_message(self.hass, 'test_light_rgb/status', 'ON') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual((0, 0), state.attributes.get('hs_color')) + def test_show_white_value_if_only_command_topic(self): """Test the white_value if only a command topic is present.""" config = {light.DOMAIN: { @@ -822,6 +929,39 @@ class TestLightMQTT(unittest.TestCase): mock.call('test_light/bright', 50, 0, False) ], any_order=True) + def test_on_command_rgb(self): + """Test on command in RGB brightness mode.""" + config = {light.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test_light/set', + 'rgb_command_topic': "test_light/rgb", + }} + + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, config) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + common.turn_on(self.hass, 'light.test', brightness=127) + self.hass.block_till_done() + + # Should get the following MQTT messages. + # test_light/rgb: '127,127,127' + # test_light/set: 'ON' + self.mock_publish.async_publish.assert_has_calls([ + mock.call('test_light/rgb', '127,127,127', 0, False), + mock.call('test_light/set', 'ON', 0, False), + ], any_order=True) + self.mock_publish.async_publish.reset_mock() + + common.turn_off(self.hass, 'light.test') + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light/set', 'OFF', 0, False) + def test_default_availability_payload(self): """Test availability by default payload with defined topic.""" self.assertTrue(setup_component(self.hass, light.DOMAIN, { diff --git a/tests/components/lock/test_template.py b/tests/components/lock/test_template.py new file mode 100644 index 00000000000..7b67a68bde1 --- /dev/null +++ b/tests/components/lock/test_template.py @@ -0,0 +1,309 @@ +"""The tests for the Template lock platform.""" +import logging + +from homeassistant.core import callback +from homeassistant import setup +from homeassistant.components import lock +from homeassistant.const import STATE_ON, STATE_OFF + +from tests.common import (get_test_home_assistant, + assert_setup_component) + +_LOGGER = logging.getLogger(__name__) + + +class TestTemplateLock: + """Test the Template lock.""" + + hass = None + calls = None + # pylint: disable=invalid-name + + def setup_method(self, method): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.calls = [] + + @callback + def record_call(service): + """Track function calls.""" + self.calls.append(service) + + self.hass.services.register('test', 'automation', record_call) + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_template_state(self): + """Test template.""" + with assert_setup_component(1, 'lock'): + assert setup.setup_component(self.hass, 'lock', { + 'lock': { + 'platform': 'template', + 'name': 'Test template lock', + 'value_template': + "{{ states.switch.test_state.state }}", + 'lock': { + 'service': 'switch.turn_on', + 'entity_id': 'switch.test_state' + }, + 'unlock': { + 'service': 'switch.turn_off', + 'entity_id': 'switch.test_state' + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + self.hass.states.set('switch.test_state', STATE_ON) + self.hass.block_till_done() + + state = self.hass.states.get('lock.test_template_lock') + assert state.state == lock.STATE_LOCKED + + self.hass.states.set('switch.test_state', STATE_OFF) + self.hass.block_till_done() + + state = self.hass.states.get('lock.test_template_lock') + assert state.state == lock.STATE_UNLOCKED + + def test_template_state_boolean_on(self): + """Test the setting of the state with boolean on.""" + with assert_setup_component(1, 'lock'): + assert setup.setup_component(self.hass, 'lock', { + 'lock': { + 'platform': 'template', + 'value_template': + "{{ 1 == 1 }}", + 'lock': { + 'service': 'switch.turn_on', + 'entity_id': 'switch.test_state' + }, + 'unlock': { + 'service': 'switch.turn_off', + 'entity_id': 'switch.test_state' + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('lock.template_lock') + assert state.state == lock.STATE_LOCKED + + def test_template_state_boolean_off(self): + """Test the setting of the state with off.""" + with assert_setup_component(1, 'lock'): + assert setup.setup_component(self.hass, 'lock', { + 'lock': { + 'platform': 'template', + 'value_template': + "{{ 1 == 2 }}", + 'lock': { + 'service': 'switch.turn_on', + 'entity_id': 'switch.test_state' + }, + 'unlock': { + 'service': 'switch.turn_off', + 'entity_id': 'switch.test_state' + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('lock.template_lock') + assert state.state == lock.STATE_UNLOCKED + + def test_template_syntax_error(self): + """Test templating syntax error.""" + with assert_setup_component(0, 'lock'): + assert setup.setup_component(self.hass, 'lock', { + 'lock': { + 'platform': 'template', + 'value_template': + "{% if rubbish %}", + 'lock': { + 'service': 'switch.turn_on', + 'entity_id': 'switch.test_state' + }, + 'unlock': { + 'service': 'switch.turn_off', + 'entity_id': 'switch.test_state' + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_invalid_name_does_not_create(self): + """Test invalid name.""" + with assert_setup_component(0, 'lock'): + assert setup.setup_component(self.hass, 'lock', { + 'switch': { + 'platform': 'lock', + 'name': '{{%}', + 'value_template': + "{{ rubbish }", + 'lock': { + 'service': 'switch.turn_on', + 'entity_id': 'switch.test_state' + }, + 'unlock': { + 'service': 'switch.turn_off', + 'entity_id': 'switch.test_state' + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_invalid_lock_does_not_create(self): + """Test invalid lock.""" + with assert_setup_component(0, 'lock'): + assert setup.setup_component(self.hass, 'lock', { + 'lock': { + 'platform': 'template', + 'value_template': "Invalid" + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_missing_template_does_not_create(self): + """Test missing template.""" + with assert_setup_component(0, 'lock'): + assert setup.setup_component(self.hass, 'lock', { + 'lock': { + 'platform': 'template', + 'not_value_template': + "{{ states.switch.test_state.state }}", + 'lock': { + 'service': 'switch.turn_on', + 'entity_id': 'switch.test_state' + }, + 'unlock': { + 'service': 'switch.turn_off', + 'entity_id': 'switch.test_state' + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_no_template_match_all(self, caplog): + """Test that we do not allow locks that match on all.""" + with assert_setup_component(1, 'lock'): + assert setup.setup_component(self.hass, 'lock', { + 'lock': { + 'platform': 'template', + 'value_template': '{{ 1 + 1 }}', + 'lock': { + 'service': 'switch.turn_on', + 'entity_id': 'switch.test_state' + }, + 'unlock': { + 'service': 'switch.turn_off', + 'entity_id': 'switch.test_state' + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('lock.template_lock') + assert state.state == lock.STATE_UNLOCKED + + assert ('Template lock Template Lock has no entity ids configured ' + 'to track nor were we able to extract the entities to track ' + 'from the value_template template. This entity will only ' + 'be able to be updated manually.') in caplog.text + + self.hass.states.set('lock.template_lock', lock.STATE_LOCKED) + self.hass.block_till_done() + state = self.hass.states.get('lock.template_lock') + assert state.state == lock.STATE_LOCKED + + def test_lock_action(self): + """Test lock action.""" + assert setup.setup_component(self.hass, 'lock', { + 'lock': { + 'platform': 'template', + 'value_template': + "{{ states.switch.test_state.state }}", + 'lock': { + 'service': 'test.automation' + }, + 'unlock': { + 'service': 'switch.turn_off', + 'entity_id': 'switch.test_state' + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + self.hass.states.set('switch.test_state', STATE_OFF) + self.hass.block_till_done() + + state = self.hass.states.get('lock.template_lock') + assert state.state == lock.STATE_UNLOCKED + + self.hass.services.call(lock.DOMAIN, lock.SERVICE_LOCK, { + lock.ATTR_ENTITY_ID: 'lock.template_lock' + }) + self.hass.block_till_done() + + assert len(self.calls) == 1 + + def test_unlock_action(self): + """Test unlock action.""" + assert setup.setup_component(self.hass, 'lock', { + 'lock': { + 'platform': 'template', + 'value_template': + "{{ states.switch.test_state.state }}", + 'lock': { + 'service': 'switch.turn_on', + 'entity_id': 'switch.test_state' + }, + 'unlock': { + 'service': 'test.automation' + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + self.hass.states.set('switch.test_state', STATE_ON) + self.hass.block_till_done() + + state = self.hass.states.get('lock.template_lock') + assert state.state == lock.STATE_LOCKED + + self.hass.services.call(lock.DOMAIN, lock.SERVICE_UNLOCK, { + lock.ATTR_ENTITY_ID: 'lock.template_lock' + }) + self.hass.block_till_done() + + assert len(self.calls) == 1 diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index 0fde6de902c..1ce0f9ff602 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -1,9 +1,178 @@ """Test the Lovelace initialization.""" +import os +import unittest from unittest.mock import patch +from tempfile import mkdtemp +from ruamel.yaml import YAML from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.components.lovelace import (load_yaml, + save_yaml, load_config, + UnsupportedYamlError) + +TEST_YAML_A = """\ +title: My Awesome Home +# Include external resources +resources: + - url: /local/my-custom-card.js + type: js + - url: /local/my-webfont.css + type: css + +# Exclude entities from "Unused entities" view +excluded_entities: + - weblink.router +views: + # View tab title. + - title: Example + # Optional unique id for direct access /lovelace/${id} + id: example + # Optional background (overwrites the global background). + background: radial-gradient(crimson, skyblue) + # Each view can have a different theme applied. + theme: dark-mode + # The cards to show on this view. + cards: + # The filter card will filter entities for their state + - type: entity-filter + entities: + - device_tracker.paulus + - device_tracker.anne_there + state_filter: + - 'home' + card: + type: glance + title: People that are home + + # The picture entity card will represent an entity with a picture + - type: picture-entity + image: https://www.home-assistant.io/images/default-social.png + entity: light.bed_light + + # Specify a tab icon if you want the view tab to be an icon. + - icon: mdi:home-assistant + # Title of the view. Will be used as the tooltip for tab icon + title: Second view + cards: + - id: test + type: entities + # Entities card will take a list of entities and show their state. + - type: entities + # Title of the entities card + title: Example + # The entities here will be shown in the same order as specified. + # Each entry is an entity ID or a map with extra options. + entities: + - light.kitchen + - switch.ac + - entity: light.living_room + # Override the name to use + name: LR Lights + + # The markdown card will render markdown text. + - type: markdown + title: Lovelace + content: > + Welcome to your **Lovelace UI**. +""" + +TEST_YAML_B = """\ +title: Home +views: + - title: Dashboard + id: dashboard + icon: mdi:home + cards: + - id: testid + type: vertical-stack + cards: + - type: picture-entity + entity: group.sample + name: Sample + image: /local/images/sample.jpg + tap_action: toggle +""" + +# Test data that can not be loaded as YAML +TEST_BAD_YAML = """\ +title: Home +views: + - title: Dashboard + icon: mdi:home + cards: + - id: testid + type: vertical-stack +""" + +# Test unsupported YAML +TEST_UNSUP_YAML = """\ +title: Home +views: + - title: Dashboard + icon: mdi:home + cards: !include cards.yaml +""" + + +class TestYAML(unittest.TestCase): + """Test lovelace.yaml save and load.""" + + def setUp(self): + """Set up for tests.""" + self.tmp_dir = mkdtemp() + self.yaml = YAML(typ='rt') + + def tearDown(self): + """Clean up after tests.""" + for fname in os.listdir(self.tmp_dir): + os.remove(os.path.join(self.tmp_dir, fname)) + os.rmdir(self.tmp_dir) + + def _path_for(self, leaf_name): + return os.path.join(self.tmp_dir, leaf_name+".yaml") + + def test_save_and_load(self): + """Test saving and loading back.""" + fname = self._path_for("test1") + save_yaml(fname, self.yaml.load(TEST_YAML_A)) + data = load_yaml(fname) + self.assertEqual(data, self.yaml.load(TEST_YAML_A)) + + def test_overwrite_and_reload(self): + """Test that we can overwrite an existing file and read back.""" + fname = self._path_for("test3") + save_yaml(fname, self.yaml.load(TEST_YAML_A)) + save_yaml(fname, self.yaml.load(TEST_YAML_B)) + data = load_yaml(fname) + self.assertEqual(data, self.yaml.load(TEST_YAML_B)) + + def test_load_bad_data(self): + """Test error from trying to load unserialisable data.""" + fname = self._path_for("test5") + with open(fname, "w") as fh: + fh.write(TEST_BAD_YAML) + with self.assertRaises(HomeAssistantError): + load_yaml(fname) + + def test_add_id(self): + """Test if id is added.""" + fname = self._path_for("test6") + with patch('homeassistant.components.lovelace.load_yaml', + return_value=self.yaml.load(TEST_YAML_A)), \ + patch('homeassistant.components.lovelace.save_yaml'): + data = load_config(fname) + assert 'id' in data['views'][0]['cards'][0] + assert 'id' in data['views'][1] + + def test_id_not_changed(self): + """Test if id is not changed if already exists.""" + fname = self._path_for("test7") + with patch('homeassistant.components.lovelace.load_yaml', + return_value=self.yaml.load(TEST_YAML_B)): + data = load_config(fname) + self.assertEqual(data, self.yaml.load(TEST_YAML_B)) async def test_deprecated_lovelace_ui(hass, hass_ws_client): @@ -11,7 +180,7 @@ async def test_deprecated_lovelace_ui(hass, hass_ws_client): await async_setup_component(hass, 'lovelace') client = await hass_ws_client(hass) - with patch('homeassistant.components.lovelace.load_yaml', + with patch('homeassistant.components.lovelace.load_config', return_value={'hello': 'world'}): await client.send_json({ 'id': 5, @@ -30,7 +199,7 @@ async def test_deprecated_lovelace_ui_not_found(hass, hass_ws_client): await async_setup_component(hass, 'lovelace') client = await hass_ws_client(hass) - with patch('homeassistant.components.lovelace.load_yaml', + with patch('homeassistant.components.lovelace.load_config', side_effect=FileNotFoundError): await client.send_json({ 'id': 5, @@ -49,7 +218,7 @@ async def test_deprecated_lovelace_ui_load_err(hass, hass_ws_client): await async_setup_component(hass, 'lovelace') client = await hass_ws_client(hass) - with patch('homeassistant.components.lovelace.load_yaml', + with patch('homeassistant.components.lovelace.load_config', side_effect=HomeAssistantError): await client.send_json({ 'id': 5, @@ -68,7 +237,7 @@ async def test_lovelace_ui(hass, hass_ws_client): await async_setup_component(hass, 'lovelace') client = await hass_ws_client(hass) - with patch('homeassistant.components.lovelace.load_yaml', + with patch('homeassistant.components.lovelace.load_config', return_value={'hello': 'world'}): await client.send_json({ 'id': 5, @@ -87,7 +256,7 @@ async def test_lovelace_ui_not_found(hass, hass_ws_client): await async_setup_component(hass, 'lovelace') client = await hass_ws_client(hass) - with patch('homeassistant.components.lovelace.load_yaml', + with patch('homeassistant.components.lovelace.load_config', side_effect=FileNotFoundError): await client.send_json({ 'id': 5, @@ -102,11 +271,11 @@ async def test_lovelace_ui_not_found(hass, hass_ws_client): async def test_lovelace_ui_load_err(hass, hass_ws_client): - """Test lovelace_ui command cannot find file.""" + """Test lovelace_ui command load error.""" await async_setup_component(hass, 'lovelace') client = await hass_ws_client(hass) - with patch('homeassistant.components.lovelace.load_yaml', + with patch('homeassistant.components.lovelace.load_config', side_effect=HomeAssistantError): await client.send_json({ 'id': 5, @@ -118,3 +287,209 @@ async def test_lovelace_ui_load_err(hass, hass_ws_client): assert msg['type'] == TYPE_RESULT assert msg['success'] is False assert msg['error']['code'] == 'load_error' + + +async def test_lovelace_ui_load_json_err(hass, hass_ws_client): + """Test lovelace_ui command load error.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + + with patch('homeassistant.components.lovelace.load_config', + side_effect=UnsupportedYamlError): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'unsupported_error' + + +async def test_lovelace_get_card(hass, hass_ws_client): + """Test get_card command.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.components.lovelace.load_yaml', + return_value=yaml.load(TEST_YAML_A)): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/get', + 'card_id': 'test', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + assert msg['result'] == 'id: test\ntype: entities\n' + + +async def test_lovelace_get_card_not_found(hass, hass_ws_client): + """Test get_card command cannot find card.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.components.lovelace.load_yaml', + return_value=yaml.load(TEST_YAML_A)): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/get', + 'card_id': 'not_found', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'card_not_found' + + +async def test_lovelace_get_card_bad_yaml(hass, hass_ws_client): + """Test get_card command bad yaml.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + + with patch('homeassistant.components.lovelace.load_yaml', + side_effect=HomeAssistantError): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/get', + 'card_id': 'testid', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'load_error' + + +async def test_lovelace_update_card(hass, hass_ws_client): + """Test update_card command.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.components.lovelace.load_yaml', + return_value=yaml.load(TEST_YAML_A)), \ + patch('homeassistant.components.lovelace.save_yaml') \ + as save_yaml_mock: + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/update', + 'card_id': 'test', + 'card_config': 'id: test\ntype: glance\n', + }) + msg = await client.receive_json() + + result = save_yaml_mock.call_args_list[0][0][1] + assert result.mlget(['views', 1, 'cards', 0, 'type'], + list_ok=True) == 'glance' + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + + +async def test_lovelace_update_card_not_found(hass, hass_ws_client): + """Test update_card command cannot find card.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.components.lovelace.load_yaml', + return_value=yaml.load(TEST_YAML_A)): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/update', + 'card_id': 'not_found', + 'card_config': 'id: test\ntype: glance\n', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'card_not_found' + + +async def test_lovelace_update_card_bad_yaml(hass, hass_ws_client): + """Test update_card command bad yaml.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.components.lovelace.load_yaml', + return_value=yaml.load(TEST_YAML_A)), \ + patch('homeassistant.components.lovelace.yaml_to_object', + side_effect=HomeAssistantError): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/update', + 'card_id': 'test', + 'card_config': 'id: test\ntype: glance\n', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'save_error' + + +async def test_lovelace_add_card(hass, hass_ws_client): + """Test add_card command.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.components.lovelace.load_yaml', + return_value=yaml.load(TEST_YAML_A)), \ + patch('homeassistant.components.lovelace.save_yaml') \ + as save_yaml_mock: + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/add', + 'view_id': 'example', + 'card_config': 'id: test\ntype: added\n', + }) + msg = await client.receive_json() + + result = save_yaml_mock.call_args_list[0][0][1] + assert result.mlget(['views', 0, 'cards', 2, 'type'], + list_ok=True) == 'added' + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + + +async def test_lovelace_add_card_position(hass, hass_ws_client): + """Test add_card command.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.components.lovelace.load_yaml', + return_value=yaml.load(TEST_YAML_A)), \ + patch('homeassistant.components.lovelace.save_yaml') \ + as save_yaml_mock: + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/card/add', + 'view_id': 'example', + 'position': 0, + 'card_config': 'id: test\ntype: added\n', + }) + msg = await client.receive_json() + + result = save_yaml_mock.call_args_list[0][0][1] + assert result.mlget(['views', 0, 'cards', 0, 'type'], + list_ok=True) == 'added' + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] diff --git a/tests/components/media_player/test_samsungtv.py b/tests/components/media_player/test_samsungtv.py index 5551a86df05..dda6562af77 100644 --- a/tests/components/media_player/test_samsungtv.py +++ b/tests/components/media_player/test_samsungtv.py @@ -2,7 +2,6 @@ import asyncio import unittest from unittest.mock import call, patch, MagicMock -from subprocess import CalledProcessError from asynctest import mock @@ -13,8 +12,8 @@ from homeassistant.components.media_player import SUPPORT_TURN_ON, \ MEDIA_TYPE_CHANNEL, MEDIA_TYPE_URL from homeassistant.components.media_player.samsungtv import setup_platform, \ CONF_TIMEOUT, SamsungTVDevice, SUPPORT_SAMSUNGTV -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, STATE_ON, \ - CONF_MAC, STATE_OFF +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_MAC, \ + STATE_OFF from tests.common import MockDependency from homeassistant.util import dt as dt_util from datetime import timedelta @@ -100,55 +99,24 @@ class TestSamsungTv(unittest.TestCase): mocked_warn.assert_called_once_with("Cannot determine device") add_entities.assert_not_called() - @mock.patch( - 'homeassistant.components.media_player.samsungtv.subprocess.Popen' - ) - def test_update_on(self, mocked_popen): + def test_update_on(self): """Testing update tv on.""" - ping = mock.Mock() - mocked_popen.return_value = ping - ping.returncode = 0 self.device.update() - self.assertEqual(STATE_ON, self.device._state) + self.assertEqual(None, self.device._state) - @mock.patch( - 'homeassistant.components.media_player.samsungtv.subprocess.Popen' - ) - def test_update_off(self, mocked_popen): + def test_update_off(self): """Testing update tv off.""" - ping = mock.Mock() - mocked_popen.return_value = ping - ping.returncode = 1 + _remote = mock.Mock() + _remote.control = mock.Mock( + side_effect=OSError('Boom')) + self.device.get_remote = mock.Mock(return_value=_remote) self.device.update() self.assertEqual(STATE_OFF, self.device._state) - ping = mock.Mock() - ping.communicate = mock.Mock( - side_effect=CalledProcessError("BOOM", None)) - mocked_popen.return_value = ping - self.device.update() - self.assertEqual(STATE_OFF, self.device._state) - - @mock.patch( - 'homeassistant.components.media_player.samsungtv.subprocess.Popen' - ) - def test_timeout(self, mocked_popen): - """Test timeout use.""" - ping = mock.Mock() - mocked_popen.return_value = ping - ping.returncode = 0 - self.device.update() - expected_timeout = self.device._config['timeout'] - timeout_arg = '-W{}'.format(expected_timeout) - ping_command = [ - 'ping', '-n', '-q', '-c3', timeout_arg, 'fake'] - expected_call = call(ping_command, stderr=-3, stdout=-1) - self.assertEqual(mocked_popen.call_args, expected_call) - self.assertEqual(STATE_ON, self.device._state) def test_send_key(self): """Test for send key.""" self.device.send_key('KEY_POWER') - self.assertEqual(STATE_ON, self.device._state) + self.assertEqual(None, self.device._state) def test_send_key_broken_pipe(self): """Testing broken pipe Exception.""" @@ -158,7 +126,7 @@ class TestSamsungTv(unittest.TestCase): self.device.get_remote = mock.Mock(return_value=_remote) self.device.send_key('HELLO') self.assertIsNone(self.device._remote) - self.assertEqual(STATE_ON, self.device._state) + self.assertEqual(None, self.device._state) def test_send_key_connection_closed_retry_succeed(self): """Test retry on connection closed.""" @@ -169,7 +137,7 @@ class TestSamsungTv(unittest.TestCase): self.device.get_remote = mock.Mock(return_value=_remote) command = 'HELLO' self.device.send_key(command) - self.assertEqual(STATE_ON, self.device._state) + self.assertEqual(None, self.device._state) # verify that _remote.control() get called twice because of retry logic expected = [mock.call(command), mock.call(command)] @@ -184,7 +152,7 @@ class TestSamsungTv(unittest.TestCase): self.device.get_remote = mock.Mock(return_value=_remote) self.device.send_key('HELLO') self.assertIsNone(self.device._remote) - self.assertEqual(STATE_ON, self.device._state) + self.assertEqual(None, self.device._state) def test_send_key_os_error(self): """Testing broken pipe Exception.""" @@ -209,8 +177,8 @@ class TestSamsungTv(unittest.TestCase): def test_state(self): """Test for state property.""" - self.device._state = STATE_ON - self.assertEqual(STATE_ON, self.device.state) + self.device._state = None + self.assertEqual(None, self.device.state) self.device._state = STATE_OFF self.assertEqual(STATE_OFF, self.device.state) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 36b022de7a6..dd3ab2e6f7a 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -5,6 +5,7 @@ from unittest.mock import patch from homeassistant.components import mqtt from homeassistant.components.mqtt.discovery import async_start, \ ALREADY_DISCOVERED +from homeassistant.const import STATE_ON, STATE_OFF from tests.common import async_fire_mqtt_message, mock_coro, MockConfigEntry @@ -208,3 +209,36 @@ def test_non_duplicate_discovery(hass, mqtt_mock, caplog): assert state_duplicate is None assert 'Component has already been discovered: ' \ 'binary_sensor bla' in caplog.text + + +@asyncio.coroutine +def test_discovery_expansion(hass, mqtt_mock, caplog): + """Test expansion of abbreviated discovery payload.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + + yield from async_start(hass, 'homeassistant', {}, entry) + + data = ( + '{ "~": "some/base/topic",' + ' "name": "DiscoveryExpansionTest1",' + ' "stat_t": "test_topic/~",' + ' "cmd_t": "~/test_topic" }' + ) + + async_fire_mqtt_message( + hass, 'homeassistant/switch/bla/config', data) + yield from hass.async_block_till_done() + + state = hass.states.get('switch.DiscoveryExpansionTest1') + assert state is not None + assert state.name == 'DiscoveryExpansionTest1' + assert ('switch', 'bla') in hass.data[ALREADY_DISCOVERED] + assert state.state == STATE_OFF + + async_fire_mqtt_message(hass, 'test_topic/some/base/topic', + 'ON') + yield from hass.async_block_till_done() + yield from hass.async_block_till_done() + + state = hass.states.get('switch.DiscoveryExpansionTest1') + assert state.state == STATE_ON diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 831bcaa1d24..045a411a271 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -190,6 +190,47 @@ class TestMQTTComponent(unittest.TestCase): # Topic names beginning with $ SHOULD NOT be used, but can mqtt.valid_publish_topic('$SYS/') + def test_entity_device_info_schema(self): + """Test MQTT entity device info validation.""" + # just identifier + mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA({ + 'identifiers': ['abcd'] + }) + mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA({ + 'identifiers': 'abcd' + }) + # just connection + mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA({ + 'connections': [ + ['mac', '02:5b:26:a8:dc:12'], + ] + }) + # full device info + mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA({ + 'identifiers': ['helloworld', 'hello'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ["zigbee", "zigbee_id"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }) + # no identifiers + self.assertRaises(vol.Invalid, mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, { + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }) + # empty identifiers + self.assertRaises(vol.Invalid, mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, { + 'identifiers': [], + 'connections': [], + 'name': 'Beer', + }) + # pylint: disable=invalid-name class TestMQTTCallbacks(unittest.TestCase): diff --git a/tests/components/notify/test_homematic.py b/tests/components/notify/test_homematic.py new file mode 100644 index 00000000000..2ea98fc020b --- /dev/null +++ b/tests/components/notify/test_homematic.py @@ -0,0 +1,78 @@ +"""The tests for the Homematic notification platform.""" + +import unittest + +from homeassistant.setup import setup_component +import homeassistant.components.notify as notify_comp +from tests.common import assert_setup_component, get_test_home_assistant + + +class TestHomematicNotify(unittest.TestCase): + """Test the Homematic notifications.""" + + def setUp(self): # pylint: disable=invalid-name + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_setup_full(self): + """Test valid configuration.""" + setup_component(self.hass, 'homematic', { + 'homematic': { + 'hosts': { + 'ccu2': { + 'host': '127.0.0.1' + } + } + } + }) + with assert_setup_component(1) as handle_config: + assert setup_component(self.hass, 'notify', { + 'notify': { + 'name': 'test', + 'platform': 'homematic', + 'address': 'NEQXXXXXXX', + 'channel': 2, + 'param': 'SUBMIT', + 'value': '1,1,108000,2', + 'interface': 'my-interface'} + }) + assert handle_config[notify_comp.DOMAIN] + + def test_setup_without_optional(self): + """Test valid configuration without optional.""" + setup_component(self.hass, 'homematic', { + 'homematic': { + 'hosts': { + 'ccu2': { + 'host': '127.0.0.1' + } + } + } + }) + with assert_setup_component(1) as handle_config: + assert setup_component(self.hass, 'notify', { + 'notify': { + 'name': 'test', + 'platform': 'homematic', + 'address': 'NEQXXXXXXX', + 'channel': 2, + 'param': 'SUBMIT', + 'value': '1,1,108000,2'} + }) + assert handle_config[notify_comp.DOMAIN] + + def test_bad_config(self): + """Test invalid configuration.""" + config = { + notify_comp.DOMAIN: { + 'name': 'test', + 'platform': 'homematic' + } + } + with assert_setup_component(0) as handle_config: + assert setup_component(self.hass, notify_comp.DOMAIN, config) + assert not handle_config[notify_comp.DOMAIN] diff --git a/tests/components/openuv/test_config_flow.py b/tests/components/openuv/test_config_flow.py index 07946fbbc09..60aa990333f 100644 --- a/tests/components/openuv/test_config_flow.py +++ b/tests/components/openuv/test_config_flow.py @@ -1,10 +1,12 @@ """Define tests for the OpenUV config flow.""" +from datetime import timedelta from unittest.mock import patch from homeassistant import data_entry_flow from homeassistant.components.openuv import DOMAIN, config_flow from homeassistant.const import ( - CONF_API_KEY, CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE) + CONF_API_KEY, CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, + CONF_SCAN_INTERVAL) from tests.common import MockConfigEntry, mock_coro @@ -23,7 +25,7 @@ async def test_duplicate_error(hass): flow.hass = hass result = await flow.async_step_user(user_input=conf) - assert result['errors'] == {'base': 'identifier_exists'} + assert result['errors'] == {CONF_LATITUDE: 'identifier_exists'} async def test_invalid_api_key(hass): @@ -41,7 +43,7 @@ async def test_invalid_api_key(hass): with patch('pyopenuv.util.validate_api_key', return_value=mock_coro(False)): result = await flow.async_step_user(user_input=conf) - assert result['errors'] == {'base': 'invalid_api_key'} + assert result['errors'] == {CONF_API_KEY: 'invalid_api_key'} async def test_show_form(hass): @@ -57,25 +59,6 @@ async def test_show_form(hass): async def test_step_import(hass): """Test that the import step works.""" - conf = { - CONF_API_KEY: '12345abcde', - } - - flow = config_flow.OpenUvFlowHandler() - flow.hass = hass - - with patch('pyopenuv.util.validate_api_key', - return_value=mock_coro(True)): - result = await flow.async_step_import(import_config=conf) - - assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result['title'] == '{0}, {1}'.format( - hass.config.latitude, hass.config.longitude) - assert result['data'] == conf - - -async def test_step_user(hass): - """Test that the user step works.""" conf = { CONF_API_KEY: '12345abcde', CONF_ELEVATION: 59.1234, @@ -86,11 +69,44 @@ async def test_step_user(hass): flow = config_flow.OpenUvFlowHandler() flow.hass = hass + with patch('pyopenuv.util.validate_api_key', + return_value=mock_coro(True)): + result = await flow.async_step_import(import_config=conf) + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '39.128712, -104.9812612' + assert result['data'] == { + CONF_API_KEY: '12345abcde', + CONF_ELEVATION: 59.1234, + CONF_LATITUDE: 39.128712, + CONF_LONGITUDE: -104.9812612, + CONF_SCAN_INTERVAL: 1800, + } + + +async def test_step_user(hass): + """Test that the user step works.""" + conf = { + CONF_API_KEY: '12345abcde', + CONF_ELEVATION: 59.1234, + CONF_LATITUDE: 39.128712, + CONF_LONGITUDE: -104.9812612, + CONF_SCAN_INTERVAL: timedelta(minutes=5) + } + + flow = config_flow.OpenUvFlowHandler() + flow.hass = hass + with patch('pyopenuv.util.validate_api_key', return_value=mock_coro(True)): result = await flow.async_step_user(user_input=conf) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result['title'] == '{0}, {1}'.format( - conf[CONF_LATITUDE], conf[CONF_LONGITUDE]) - assert result['data'] == conf + assert result['title'] == '39.128712, -104.9812612' + assert result['data'] == { + CONF_API_KEY: '12345abcde', + CONF_ELEVATION: 59.1234, + CONF_LATITUDE: 39.128712, + CONF_LONGITUDE: -104.9812612, + CONF_SCAN_INTERVAL: 300, + } diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index b0683b04aa0..433d1aa2512 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -149,6 +149,23 @@ class TestFilterSensor(unittest.TestCase): else: self.assertEqual(unf, filtered.state) + def test_range_zero(self): + """Test if range filter works with zeroes as bounds.""" + lower = 0 + upper = 0 + filt = RangeFilter(entity=None, + lower_bound=lower, + upper_bound=upper) + for unf_state in self.values: + unf = float(unf_state.state) + filtered = filt.filter_state(unf_state) + if unf < lower: + self.assertEqual(lower, filtered.state) + elif unf > upper: + self.assertEqual(upper, filtered.state) + else: + self.assertEqual(unf, filtered.state) + def test_throttle(self): """Test if lowpass filter works.""" filt = ThrottleFilter(window_size=3, diff --git a/tests/components/sensor/test_geo_rss_events.py b/tests/components/sensor/test_geo_rss_events.py index 21538d458bc..3362f799392 100644 --- a/tests/components/sensor/test_geo_rss_events.py +++ b/tests/components/sensor/test_geo_rss_events.py @@ -123,6 +123,10 @@ class TestGeoRssServiceUpdater(unittest.TestCase): assert len(all_states) == 1 state = self.hass.states.get("sensor.event_service_any") assert int(state.state) == 0 + assert state.attributes == { + ATTR_FRIENDLY_NAME: "Event Service Any", + ATTR_UNIT_OF_MEASUREMENT: "Events", + ATTR_ICON: "mdi:alert"} @mock.patch('georss_client.generic_feed.GenericFeed') def test_setup_with_categories(self, mock_feed): diff --git a/tests/components/sensor/test_jewish_calendar.py b/tests/components/sensor/test_jewish_calendar.py index b67e340a9aa..ba3a11d862b 100644 --- a/tests/components/sensor/test_jewish_calendar.py +++ b/tests/components/sensor/test_jewish_calendar.py @@ -5,6 +5,7 @@ from datetime import datetime as dt from unittest.mock import patch from homeassistant.util.async_ import run_coroutine_threadsafe +from homeassistant.util.dt import get_time_zone from homeassistant.setup import setup_component from homeassistant.components.sensor.jewish_calendar import JewishCalSensor from tests.common import get_test_home_assistant @@ -85,7 +86,7 @@ class TestJewishCalenderSensor(unittest.TestCase): self.assertEqual(sensor.state, "כ\"ג באלול ה\' תשע\"ח") def test_jewish_calendar_sensor_holiday_name(self): - """Test Jewish calendar sensor date output in hebrew.""" + """Test Jewish calendar sensor holiday name output in hebrew.""" test_time = dt(2018, 9, 10) sensor = JewishCalSensor( name='test', language='hebrew', sensor_type='holiday_name', @@ -97,7 +98,7 @@ class TestJewishCalenderSensor(unittest.TestCase): self.assertEqual(sensor.state, "א\' ראש השנה") def test_jewish_calendar_sensor_holiday_name_english(self): - """Test Jewish calendar sensor date output in hebrew.""" + """Test Jewish calendar sensor holiday name output in english.""" test_time = dt(2018, 9, 10) sensor = JewishCalSensor( name='test', language='english', sensor_type='holiday_name', @@ -109,7 +110,7 @@ class TestJewishCalenderSensor(unittest.TestCase): self.assertEqual(sensor.state, "Rosh Hashana I") def test_jewish_calendar_sensor_holyness(self): - """Test Jewish calendar sensor date output in hebrew.""" + """Test Jewish calendar sensor holyness value.""" test_time = dt(2018, 9, 10) sensor = JewishCalSensor( name='test', language='hebrew', sensor_type='holyness', @@ -121,7 +122,7 @@ class TestJewishCalenderSensor(unittest.TestCase): self.assertEqual(sensor.state, 1) def test_jewish_calendar_sensor_torah_reading(self): - """Test Jewish calendar sensor date output in hebrew.""" + """Test Jewish calendar sensor torah reading in hebrew.""" test_time = dt(2018, 9, 8) sensor = JewishCalSensor( name='test', language='hebrew', sensor_type='weekly_portion', @@ -133,19 +134,19 @@ class TestJewishCalenderSensor(unittest.TestCase): self.assertEqual(sensor.state, "פרשת נצבים") def test_jewish_calendar_sensor_first_stars_ny(self): - """Test Jewish calendar sensor date output in hebrew.""" + """Test Jewish calendar sensor first stars time in NY, US.""" test_time = dt(2018, 9, 8) sensor = JewishCalSensor( name='test', language='hebrew', sensor_type='first_stars', latitude=40.7128, longitude=-74.0060, - timezone="America/New_York", diaspora=False) + timezone=get_time_zone("America/New_York"), diaspora=False) with patch('homeassistant.util.dt.now', return_value=test_time): run_coroutine_threadsafe( sensor.async_update(), self.hass.loop).result() self.assertEqual(sensor.state, time(19, 48)) def test_jewish_calendar_sensor_first_stars_jerusalem(self): - """Test Jewish calendar sensor date output in hebrew.""" + """Test Jewish calendar sensor first stars time in Jerusalem, IL.""" test_time = dt(2018, 9, 8) sensor = JewishCalSensor( name='test', language='hebrew', sensor_type='first_stars', @@ -155,3 +156,15 @@ class TestJewishCalenderSensor(unittest.TestCase): run_coroutine_threadsafe( sensor.async_update(), self.hass.loop).result() self.assertEqual(sensor.state, time(19, 21)) + + def test_jewish_calendar_sensor_torah_reading_weekday(self): + """Test the sensor showing torah reading also on weekdays.""" + test_time = dt(2018, 10, 14) + sensor = JewishCalSensor( + name='test', language='hebrew', sensor_type='weekly_portion', + latitude=self.TEST_LATITUDE, longitude=self.TEST_LONGITUDE, + timezone="Asia/Jerusalem", diaspora=False) + with patch('homeassistant.util.dt.now', return_value=test_time): + run_coroutine_threadsafe( + sensor.async_update(), self.hass.loop).result() + self.assertEqual(sensor.state, "פרשת לך לך") diff --git a/tests/components/sensor/test_moldindicator.py b/tests/components/sensor/test_moldindicator.py index 4f1b40bf9ef..7b2480f1298 100644 --- a/tests/components/sensor/test_moldindicator.py +++ b/tests/components/sensor/test_moldindicator.py @@ -5,8 +5,8 @@ from homeassistant.setup import setup_component import homeassistant.components.sensor as sensor from homeassistant.components.sensor.mold_indicator import (ATTR_DEWPOINT, ATTR_CRITICAL_TEMP) -from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, - TEMP_CELSIUS) +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, TEMP_CELSIUS) from tests.common import get_test_home_assistant @@ -44,7 +44,7 @@ class TestSensorMoldIndicator(unittest.TestCase): assert moldind assert '%' == moldind.attributes.get('unit_of_measurement') - def test_invalidhum(self): + def test_invalidcalib(self): """Test invalid sensor values.""" self.hass.states.set('test.indoortemp', '10', {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) @@ -53,6 +53,32 @@ class TestSensorMoldIndicator(unittest.TestCase): self.hass.states.set('test.indoorhumidity', '0', {ATTR_UNIT_OF_MEASUREMENT: '%'}) + self.assertTrue(setup_component(self.hass, sensor.DOMAIN, { + 'sensor': { + 'platform': 'mold_indicator', + 'indoor_temp_sensor': 'test.indoortemp', + 'outdoor_temp_sensor': 'test.outdoortemp', + 'indoor_humidity_sensor': 'test.indoorhumidity', + 'calibration_factor': 0 + } + })) + self.hass.start() + self.hass.block_till_done() + moldind = self.hass.states.get('sensor.mold_indicator') + assert moldind + assert moldind.state == 'unavailable' + assert moldind.attributes.get(ATTR_DEWPOINT) is None + assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None + + def test_invalidhum(self): + """Test invalid sensor values.""" + self.hass.states.set('test.indoortemp', '10', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.states.set('test.outdoortemp', '10', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.states.set('test.indoorhumidity', '-1', + {ATTR_UNIT_OF_MEASUREMENT: '%'}) + self.assertTrue(setup_component(self.hass, sensor.DOMAIN, { 'sensor': { 'platform': 'mold_indicator', @@ -62,9 +88,32 @@ class TestSensorMoldIndicator(unittest.TestCase): 'calibration_factor': 2.0 } })) + + self.hass.start() + self.hass.block_till_done() moldind = self.hass.states.get('sensor.mold_indicator') assert moldind - assert moldind.state == '0' + assert moldind.state == 'unavailable' + assert moldind.attributes.get(ATTR_DEWPOINT) is None + assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None + + self.hass.states.set('test.indoorhumidity', 'A', + {ATTR_UNIT_OF_MEASUREMENT: '%'}) + self.hass.block_till_done() + moldind = self.hass.states.get('sensor.mold_indicator') + assert moldind + assert moldind.state == 'unavailable' + assert moldind.attributes.get(ATTR_DEWPOINT) is None + assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None + + self.hass.states.set('test.indoorhumidity', '10', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + moldind = self.hass.states.get('sensor.mold_indicator') + assert moldind + assert moldind.state == 'unavailable' + assert moldind.attributes.get(ATTR_DEWPOINT) is None + assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None def test_calculation(self): """Test the mold indicator internal calculations.""" @@ -77,7 +126,8 @@ class TestSensorMoldIndicator(unittest.TestCase): 'calibration_factor': 2.0 } })) - + self.hass.start() + self.hass.block_till_done() moldind = self.hass.states.get('sensor.mold_indicator') assert moldind @@ -98,6 +148,66 @@ class TestSensorMoldIndicator(unittest.TestCase): assert state assert state == '68' + def test_unknown_sensor(self): + """Test the sensor_changed function.""" + self.assertTrue(setup_component(self.hass, sensor.DOMAIN, { + 'sensor': { + 'platform': 'mold_indicator', + 'indoor_temp_sensor': 'test.indoortemp', + 'outdoor_temp_sensor': 'test.outdoortemp', + 'indoor_humidity_sensor': 'test.indoorhumidity', + 'calibration_factor': 2.0 + } + })) + self.hass.start() + + self.hass.states.set('test.indoortemp', STATE_UNKNOWN, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + moldind = self.hass.states.get('sensor.mold_indicator') + assert moldind + assert moldind.state == 'unavailable' + assert moldind.attributes.get(ATTR_DEWPOINT) is None + assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None + + self.hass.states.set('test.indoortemp', '30', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.states.set('test.outdoortemp', STATE_UNKNOWN, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + moldind = self.hass.states.get('sensor.mold_indicator') + assert moldind + assert moldind.state == 'unavailable' + assert moldind.attributes.get(ATTR_DEWPOINT) is None + assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None + + self.hass.states.set('test.outdoortemp', '25', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.states.set('test.indoorhumidity', STATE_UNKNOWN, + {ATTR_UNIT_OF_MEASUREMENT: '%'}) + self.hass.block_till_done() + moldind = self.hass.states.get('sensor.mold_indicator') + assert moldind + assert moldind.state == 'unavailable' + assert moldind.attributes.get(ATTR_DEWPOINT) is None + assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None + + self.hass.states.set('test.indoorhumidity', '20', + {ATTR_UNIT_OF_MEASUREMENT: '%'}) + self.hass.block_till_done() + moldind = self.hass.states.get('sensor.mold_indicator') + assert moldind + assert moldind.state == '23' + + dewpoint = moldind.attributes.get(ATTR_DEWPOINT) + assert dewpoint + assert dewpoint > 4.58 + assert dewpoint < 4.59 + + esttemp = moldind.attributes.get(ATTR_CRITICAL_TEMP) + assert esttemp + assert esttemp == 27.5 + def test_sensor_changed(self): """Test the sensor_changed function.""" self.assertTrue(setup_component(self.hass, sensor.DOMAIN, { @@ -109,6 +219,7 @@ class TestSensorMoldIndicator(unittest.TestCase): 'calibration_factor': 2.0 } })) + self.hass.start() self.hass.states.set('test.indoortemp', '30', {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) @@ -121,6 +232,6 @@ class TestSensorMoldIndicator(unittest.TestCase): assert self.hass.states.get('sensor.mold_indicator').state == '57' self.hass.states.set('test.indoorhumidity', '20', - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + {ATTR_UNIT_OF_MEASUREMENT: '%'}) self.hass.block_till_done() assert self.hass.states.get('sensor.mold_indicator').state == '23' diff --git a/tests/components/sensor/test_mqtt.py b/tests/components/sensor/test_mqtt.py index 4f70c37e04f..873de5a9bd6 100644 --- a/tests/components/sensor/test_mqtt.py +++ b/tests/components/sensor/test_mqtt.py @@ -1,4 +1,5 @@ """The tests for the MQTT sensor platform.""" +import json import unittest from datetime import timedelta, datetime @@ -411,3 +412,41 @@ async def test_discovery_removal_sensor(hass, mqtt_mock, caplog): await hass.async_block_till_done() state = hass.states.get('sensor.beer') assert state is None + + +async def test_entity_device_info_with_identifier(hass, mqtt_mock): + """Test MQTT sensor device registry integration.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + data = json.dumps({ + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'device': { + 'identifiers': ['helloworld'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }, + 'unique_id': 'veryunique' + }) + async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.identifiers == {('mqtt', 'helloworld')} + assert device.connections == {('mac', "02:5b:26:a8:dc:12")} + assert device.manufacturer == 'Whatever' + assert device.name == 'Beer' + assert device.model == 'Glass' + assert device.sw_version == '0.1-beta' diff --git a/tests/components/sensor/test_rmvtransport.py b/tests/components/sensor/test_rmvtransport.py index 9db19ecde49..d917edf0029 100644 --- a/tests/components/sensor/test_rmvtransport.py +++ b/tests/components/sensor/test_rmvtransport.py @@ -1,14 +1,17 @@ """The tests for the rmvtransport platform.""" -import unittest -from unittest.mock import patch import datetime +from unittest.mock import patch -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component -from tests.common import get_test_home_assistant +from tests.common import mock_coro -VALID_CONFIG_MINIMAL = {'sensor': {'platform': 'rmvtransport', - 'next_departure': [{'station': '3000010'}]}} + +VALID_CONFIG_MINIMAL = {'sensor': { + 'platform': 'rmvtransport', + 'next_departure': [ + {'station': '3000010'} + ]}} VALID_CONFIG_NAME = {'sensor': { 'platform': 'rmvtransport', @@ -41,8 +44,7 @@ VALID_CONFIG_DEST = {'sensor': { ]}} -def get_departuresMock(stationId, maxJourneys, - products): # pylint: disable=invalid-name +def get_departures_mock(): """Mock rmvtransport departures loading.""" data = {'station': 'Frankfurt (Main) Hauptbahnhof', 'stationId': '3000010', 'filter': '11111111111', 'journeys': [ @@ -97,77 +99,77 @@ def get_departuresMock(stationId, maxJourneys, return data -def get_errDeparturesMock(stationId, maxJourneys, - products): # pylint: disable=invalid-name - """Mock rmvtransport departures erroneous loading.""" - raise ValueError +def get_no_departures_mock(): + """Mock no departures in results.""" + data = {'station': 'Frankfurt (Main) Hauptbahnhof', + 'stationId': '3000010', + 'filter': '11111111111', + 'journeys': []} + return data -class TestRMVtransportSensor(unittest.TestCase): - """Test the rmvtransport sensor.""" +async def test_rmvtransport_min_config(hass): + """Test minimal rmvtransport configuration.""" + with patch('RMVtransport.RMVtransport.get_departures', + return_value=mock_coro(get_departures_mock())): + assert await async_setup_component(hass, 'sensor', + VALID_CONFIG_MINIMAL) is True - def setUp(self): - """Set up things to run when tests begin.""" - self.hass = get_test_home_assistant() - self.config = VALID_CONFIG_MINIMAL - self.reference = {} - self.entities = [] + state = hass.states.get('sensor.frankfurt_main_hauptbahnhof') + assert state.state == '7' + assert state.attributes['departure_time'] == \ + datetime.datetime(2018, 8, 6, 14, 21) + assert state.attributes['direction'] == \ + 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife' + assert state.attributes['product'] == 'Tram' + assert state.attributes['line'] == 12 + assert state.attributes['icon'] == 'mdi:tram' + assert state.attributes['friendly_name'] == 'Frankfurt (Main) Hauptbahnhof' - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() - @patch('RMVtransport.RMVtransport.get_departures', - side_effect=get_departuresMock) - def test_rmvtransport_min_config(self, mock_get_departures): - """Test minimal rmvtransport configuration.""" - assert setup_component(self.hass, 'sensor', VALID_CONFIG_MINIMAL) - state = self.hass.states.get('sensor.frankfurt_main_hauptbahnhof') - self.assertEqual(state.state, '7') - self.assertEqual(state.attributes['departure_time'], - datetime.datetime(2018, 8, 6, 14, 21)) - self.assertEqual(state.attributes['direction'], - 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife') - self.assertEqual(state.attributes['product'], 'Tram') - self.assertEqual(state.attributes['line'], 12) - self.assertEqual(state.attributes['icon'], 'mdi:tram') - self.assertEqual(state.attributes['friendly_name'], - 'Frankfurt (Main) Hauptbahnhof') +async def test_rmvtransport_name_config(hass): + """Test custom name configuration.""" + with patch('RMVtransport.RMVtransport.get_departures', + return_value=mock_coro(get_departures_mock())): + assert await async_setup_component(hass, 'sensor', VALID_CONFIG_NAME) - @patch('RMVtransport.RMVtransport.get_departures', - side_effect=get_departuresMock) - def test_rmvtransport_name_config(self, mock_get_departures): - """Test custom name configuration.""" - assert setup_component(self.hass, 'sensor', VALID_CONFIG_NAME) - state = self.hass.states.get('sensor.my_station') - self.assertEqual(state.attributes['friendly_name'], 'My Station') + state = hass.states.get('sensor.my_station') + assert state.attributes['friendly_name'] == 'My Station' - @patch('RMVtransport.RMVtransport.get_departures', - side_effect=get_errDeparturesMock) - def test_rmvtransport_err_config(self, mock_get_departures): - """Test erroneous rmvtransport configuration.""" - assert setup_component(self.hass, 'sensor', VALID_CONFIG_MINIMAL) - @patch('RMVtransport.RMVtransport.get_departures', - side_effect=get_departuresMock) - def test_rmvtransport_misc_config(self, mock_get_departures): - """Test misc configuration.""" - assert setup_component(self.hass, 'sensor', VALID_CONFIG_MISC) - state = self.hass.states.get('sensor.frankfurt_main_hauptbahnhof') - self.assertEqual(state.attributes['friendly_name'], - 'Frankfurt (Main) Hauptbahnhof') - self.assertEqual(state.attributes['line'], 21) +async def test_rmvtransport_misc_config(hass): + """Test misc configuration.""" + with patch('RMVtransport.RMVtransport.get_departures', + return_value=mock_coro(get_departures_mock())): + assert await async_setup_component(hass, 'sensor', VALID_CONFIG_MISC) - @patch('RMVtransport.RMVtransport.get_departures', - side_effect=get_departuresMock) - def test_rmvtransport_dest_config(self, mock_get_departures): - """Test misc configuration.""" - assert setup_component(self.hass, 'sensor', VALID_CONFIG_DEST) - state = self.hass.states.get('sensor.frankfurt_main_hauptbahnhof') - self.assertEqual(state.state, '11') - self.assertEqual(state.attributes['direction'], - 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife') - self.assertEqual(state.attributes['line'], 12) - self.assertEqual(state.attributes['minutes'], 11) - self.assertEqual(state.attributes['departure_time'], - datetime.datetime(2018, 8, 6, 14, 25)) + state = hass.states.get('sensor.frankfurt_main_hauptbahnhof') + assert state.attributes['friendly_name'] == 'Frankfurt (Main) Hauptbahnhof' + assert state.attributes['line'] == 21 + + +async def test_rmvtransport_dest_config(hass): + """Test destination configuration.""" + with patch('RMVtransport.RMVtransport.get_departures', + return_value=mock_coro(get_departures_mock())): + assert await async_setup_component(hass, 'sensor', VALID_CONFIG_DEST) + + state = hass.states.get('sensor.frankfurt_main_hauptbahnhof') + assert state.state == '11' + assert state.attributes['direction'] == \ + 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife' + assert state.attributes['line'] == 12 + assert state.attributes['minutes'] == 11 + assert state.attributes['departure_time'] == \ + datetime.datetime(2018, 8, 6, 14, 25) + + +async def test_rmvtransport_no_departures(hass): + """Test for no departures.""" + with patch('RMVtransport.RMVtransport.get_departures', + return_value=mock_coro(get_no_departures_mock())): + assert await async_setup_component(hass, 'sensor', + VALID_CONFIG_MINIMAL) + + state = hass.states.get('sensor.frankfurt_main_hauptbahnhof') + assert not state diff --git a/tests/components/sensor/test_template.py b/tests/components/sensor/test_template.py index 2211f092d7b..b0b1ea22285 100644 --- a/tests/components/sensor/test_template.py +++ b/tests/components/sensor/test_template.py @@ -1,5 +1,6 @@ """The test for the Template sensor platform.""" -from homeassistant.setup import setup_component +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.setup import setup_component, async_setup_component from tests.common import get_test_home_assistant, assert_setup_component @@ -52,7 +53,8 @@ class TestTemplateSensor: 'platform': 'template', 'sensors': { 'test_template_sensor': { - 'value_template': "State", + 'value_template': + "{{ states.sensor.test_state.state }}", 'icon_template': "{% if states.sensor.test_state.state == " "'Works' %}" @@ -82,7 +84,8 @@ class TestTemplateSensor: 'platform': 'template', 'sensors': { 'test_template_sensor': { - 'value_template': "State", + 'value_template': + "{{ states.sensor.test_state.state }}", 'entity_picture_template': "{% if states.sensor.test_state.state == " "'Works' %}" @@ -112,7 +115,8 @@ class TestTemplateSensor: 'platform': 'template', 'sensors': { 'test_template_sensor': { - 'value_template': "State", + 'value_template': + "{{ states.sensor.test_state.state }}", 'friendly_name_template': "It {{ states.sensor.test_state.state }}." } @@ -276,7 +280,8 @@ class TestTemplateSensor: 'platform': 'template', 'sensors': { 'test': { - 'value_template': '{{ foo }}', + 'value_template': + '{{ states.sensor.test_sensor.state }}', 'device_class': 'foobarnotreal', }, }, @@ -291,10 +296,14 @@ class TestTemplateSensor: 'platform': 'template', 'sensors': { 'test1': { - 'value_template': '{{ foo }}', + 'value_template': + '{{ states.sensor.test_sensor.state }}', 'device_class': 'temperature', }, - 'test2': {'value_template': '{{ foo }}'}, + 'test2': { + 'value_template': + '{{ states.sensor.test_sensor.state }}' + }, } } }) @@ -304,3 +313,83 @@ class TestTemplateSensor: assert state.attributes['device_class'] == 'temperature' state = self.hass.states.get('sensor.test2') assert 'device_class' not in state.attributes + + +async def test_no_template_match_all(hass, caplog): + """Test that we do not allow sensors that match on all.""" + hass.states.async_set('sensor.test_sensor', 'startup') + + await async_setup_component(hass, 'sensor', { + 'sensor': { + 'platform': 'template', + 'sensors': { + 'invalid_state': { + 'value_template': '{{ 1 + 1 }}', + }, + 'invalid_icon': { + 'value_template': + '{{ states.sensor.test_sensor.state }}', + 'icon_template': '{{ 1 + 1 }}', + }, + 'invalid_entity_picture': { + 'value_template': + '{{ states.sensor.test_sensor.state }}', + 'entity_picture_template': '{{ 1 + 1 }}', + }, + 'invalid_friendly_name': { + 'value_template': + '{{ states.sensor.test_sensor.state }}', + 'friendly_name_template': '{{ 1 + 1 }}', + }, + } + } + }) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 5 + assert ('Template sensor invalid_state has no entity ids ' + 'configured to track nor were we able to extract the entities to ' + 'track from the value template') in caplog.text + assert ('Template sensor invalid_icon has no entity ids ' + 'configured to track nor were we able to extract the entities to ' + 'track from the icon template') in caplog.text + assert ('Template sensor invalid_entity_picture has no entity ids ' + 'configured to track nor were we able to extract the entities to ' + 'track from the entity_picture template') in caplog.text + assert ('Template sensor invalid_friendly_name has no entity ids ' + 'configured to track nor were we able to extract the entities to ' + 'track from the friendly_name template') in caplog.text + + assert hass.states.get('sensor.invalid_state').state == 'unknown' + assert hass.states.get('sensor.invalid_icon').state == 'unknown' + assert hass.states.get('sensor.invalid_entity_picture').state == 'unknown' + assert hass.states.get('sensor.invalid_friendly_name').state == 'unknown' + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + assert hass.states.get('sensor.invalid_state').state == '2' + assert hass.states.get('sensor.invalid_icon').state == 'startup' + assert hass.states.get('sensor.invalid_entity_picture').state == 'startup' + assert hass.states.get('sensor.invalid_friendly_name').state == 'startup' + + hass.states.async_set('sensor.test_sensor', 'hello') + await hass.async_block_till_done() + + assert hass.states.get('sensor.invalid_state').state == '2' + assert hass.states.get('sensor.invalid_icon').state == 'startup' + assert hass.states.get('sensor.invalid_entity_picture').state == 'startup' + assert hass.states.get('sensor.invalid_friendly_name').state == 'startup' + + await hass.helpers.entity_component.async_update_entity( + 'sensor.invalid_state') + await hass.helpers.entity_component.async_update_entity( + 'sensor.invalid_icon') + await hass.helpers.entity_component.async_update_entity( + 'sensor.invalid_entity_picture') + await hass.helpers.entity_component.async_update_entity( + 'sensor.invalid_friendly_name') + + assert hass.states.get('sensor.invalid_state').state == '2' + assert hass.states.get('sensor.invalid_icon').state == 'hello' + assert hass.states.get('sensor.invalid_entity_picture').state == 'hello' + assert hass.states.get('sensor.invalid_friendly_name').state == 'hello' diff --git a/tests/components/sensor/test_transport_nsw.py b/tests/components/sensor/test_transport_nsw.py new file mode 100644 index 00000000000..fe933272962 --- /dev/null +++ b/tests/components/sensor/test_transport_nsw.py @@ -0,0 +1,50 @@ +"""The tests for the Transport NSW (AU) sensor platform.""" +import unittest +from unittest.mock import patch + +from homeassistant.setup import setup_component + +from tests.common import get_test_home_assistant + +VALID_CONFIG = {'sensor': { + 'platform': 'transport_nsw', + 'stop_id': '209516', + 'route': '199', + 'api_key': 'YOUR_API_KEY'} + } + + +def get_departuresMock(_stop_id, route, api_key): + """Mock TransportNSW departures loading.""" + data = { + 'stop_id': '209516', + 'route': '199', + 'due': 16, + 'delay': 6, + 'real_time': 'y' + } + return data + + +class TestRMVtransportSensor(unittest.TestCase): + """Test the TransportNSW sensor.""" + + def setUp(self): + """Set up things to run when tests begin.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('TransportNSW.TransportNSW.get_departures', + side_effect=get_departuresMock) + def test_transportnsw_config(self, mock_get_departures): + """Test minimal TransportNSW configuration.""" + assert setup_component(self.hass, 'sensor', VALID_CONFIG) + state = self.hass.states.get('sensor.next_bus') + self.assertEqual(state.state, '16') + self.assertEqual(state.attributes['stop_id'], '209516') + self.assertEqual(state.attributes['route'], '199') + self.assertEqual(state.attributes['delay'], 6) + self.assertEqual(state.attributes['real_time'], 'y') diff --git a/tests/components/simplisafe/__init__.py b/tests/components/simplisafe/__init__.py new file mode 100644 index 00000000000..b1cc391eec9 --- /dev/null +++ b/tests/components/simplisafe/__init__.py @@ -0,0 +1 @@ +"""Define tests for the SimpliSafe component.""" diff --git a/tests/components/simplisafe/test_config_flow.py b/tests/components/simplisafe/test_config_flow.py new file mode 100644 index 00000000000..63b932ee681 --- /dev/null +++ b/tests/components/simplisafe/test_config_flow.py @@ -0,0 +1,120 @@ +"""Define tests for the SimpliSafe config flow.""" +import json +from datetime import timedelta +from unittest.mock import mock_open, patch, MagicMock, PropertyMock + +from homeassistant import data_entry_flow +from homeassistant.components.simplisafe import DOMAIN, config_flow +from homeassistant.const import ( + CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_USERNAME) + +from tests.common import MockConfigEntry, mock_coro + + +def mock_api(): + """Mock SimpliSafe API class.""" + api = MagicMock() + type(api).refresh_token = PropertyMock(return_value='12345abc') + return api + + +async def test_duplicate_error(hass): + """Test that errors are shown when duplicates are added.""" + conf = { + CONF_USERNAME: 'user@email.com', + CONF_PASSWORD: 'password', + } + + MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass) + flow = config_flow.SimpliSafeFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_USERNAME: 'identifier_exists'} + + +async def test_invalid_credentials(hass): + """Test that invalid credentials throws an error.""" + from simplipy.errors import SimplipyError + conf = { + CONF_USERNAME: 'user@email.com', + CONF_PASSWORD: 'password', + } + + flow = config_flow.SimpliSafeFlowHandler() + flow.hass = hass + + with patch('simplipy.API.login_via_credentials', + return_value=mock_coro(exception=SimplipyError)): + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {'base': 'invalid_credentials'} + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + flow = config_flow.SimpliSafeFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=None) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +async def test_step_import(hass): + """Test that the import step works.""" + conf = { + CONF_USERNAME: 'user@email.com', + CONF_PASSWORD: 'password', + } + + flow = config_flow.SimpliSafeFlowHandler() + flow.hass = hass + + mop = mock_open(read_data=json.dumps({'refresh_token': '12345'})) + + with patch('simplipy.API.login_via_credentials', + return_value=mock_coro(return_value=mock_api())): + with patch('homeassistant.util.json.open', mop, create=True): + with patch('homeassistant.util.json.os.open', return_value=0): + with patch('homeassistant.util.json.os.replace'): + result = await flow.async_step_import(import_config=conf) + + assert result[ + 'type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == 'user@email.com' + assert result['data'] == { + CONF_USERNAME: 'user@email.com', + CONF_TOKEN: '12345abc', + CONF_SCAN_INTERVAL: 30, + } + + +async def test_step_user(hass): + """Test that the user step works.""" + conf = { + CONF_USERNAME: 'user@email.com', + CONF_PASSWORD: 'password', + CONF_SCAN_INTERVAL: timedelta(seconds=90), + } + + flow = config_flow.SimpliSafeFlowHandler() + flow.hass = hass + + mop = mock_open(read_data=json.dumps({'refresh_token': '12345'})) + + with patch('simplipy.API.login_via_credentials', + return_value=mock_coro(return_value=mock_api())): + with patch('homeassistant.util.json.open', mop, create=True): + with patch('homeassistant.util.json.os.open', return_value=0): + with patch('homeassistant.util.json.os.replace'): + result = await flow.async_step_user(user_input=conf) + + assert result[ + 'type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == 'user@email.com' + assert result['data'] == { + CONF_USERNAME: 'user@email.com', + CONF_TOKEN: '12345abc', + CONF_SCAN_INTERVAL: 90, + } diff --git a/tests/components/smhi/__init__.py b/tests/components/smhi/__init__.py new file mode 100644 index 00000000000..100b1f1bbb1 --- /dev/null +++ b/tests/components/smhi/__init__.py @@ -0,0 +1 @@ +"""Tests for the SMHI component.""" diff --git a/tests/components/smhi/common.py b/tests/components/smhi/common.py new file mode 100644 index 00000000000..ecf904ac9c9 --- /dev/null +++ b/tests/components/smhi/common.py @@ -0,0 +1,11 @@ +"""Common test utilities.""" +from unittest.mock import Mock + + +class AsyncMock(Mock): + """Implements Mock async.""" + + # pylint: disable=W0235 + async def __call__(self, *args, **kwargs): + """Hack for async support for Mock.""" + return super(AsyncMock, self).__call__(*args, **kwargs) diff --git a/tests/components/smhi/test_config_flow.py b/tests/components/smhi/test_config_flow.py new file mode 100644 index 00000000000..b4e543231d9 --- /dev/null +++ b/tests/components/smhi/test_config_flow.py @@ -0,0 +1,276 @@ +"""Tests for SMHI config flow.""" +from unittest.mock import Mock, patch + +from smhi.smhi_lib import Smhi as SmhiApi, SmhiForecastException + +from tests.common import mock_coro + +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.components.smhi import config_flow + + +# pylint: disable=W0212 +async def test_homeassistant_location_exists() -> None: + """Test if homeassistant location exists it should return True.""" + hass = Mock() + flow = config_flow.SmhiFlowHandler() + flow.hass = hass + with patch.object(flow, '_check_location', + return_value=mock_coro(True)): + # Test exists + hass.config.location_name = 'Home' + hass.config.latitude = 17.8419 + hass.config.longitude = 59.3262 + + assert await flow._homeassistant_location_exists() is True + + # Test not exists + hass.config.location_name = None + hass.config.latitude = 0 + hass.config.longitude = 0 + + assert await flow._homeassistant_location_exists() is False + + +async def test_name_in_configuration_exists() -> None: + """Test if home location exists in configuration.""" + hass = Mock() + flow = config_flow.SmhiFlowHandler() + flow.hass = hass + + # Test exists + hass.config.location_name = 'Home' + hass.config.latitude = 17.8419 + hass.config.longitude = 59.3262 + + # Check not exists + with patch.object(config_flow, 'smhi_locations', + return_value={ + 'test': 'something', 'test2': 'something else' + }): + + assert flow._name_in_configuration_exists('no_exist_name') is False + + # Check exists + with patch.object(config_flow, 'smhi_locations', + return_value={ + 'test': 'something', 'name_exist': 'config' + }): + + assert flow._name_in_configuration_exists('name_exist') is True + + +def test_smhi_locations(hass) -> None: + """Test return empty set.""" + locations = config_flow.smhi_locations(hass) + assert not locations + + +async def test_show_config_form() -> None: + """Test show configuration form.""" + hass = Mock() + flow = config_flow.SmhiFlowHandler() + flow.hass = hass + + result = await flow._show_config_form() + + assert result['type'] == 'form' + assert result['step_id'] == 'user' + + +async def test_show_config_form_default_values() -> None: + """Test show configuration form.""" + hass = Mock() + flow = config_flow.SmhiFlowHandler() + flow.hass = hass + + result = await flow._show_config_form( + name="test", latitude='65', longitude='17') + + assert result['type'] == 'form' + assert result['step_id'] == 'user' + + +async def test_flow_with_home_location(hass) -> None: + """Test config flow . + + Tests the flow when a default location is configured + then it should return a form with default values + """ + flow = config_flow.SmhiFlowHandler() + flow.hass = hass + + with patch.object(flow, '_check_location', + return_value=mock_coro(True)): + hass.config.location_name = 'Home' + hass.config.latitude = 17.8419 + hass.config.longitude = 59.3262 + + result = await flow.async_step_user() + assert result['type'] == 'form' + assert result['step_id'] == 'user' + + +async def test_flow_show_form() -> None: + """Test show form scenarios first time. + + Test when the form should show when no configurations exists + """ + hass = Mock() + flow = config_flow.SmhiFlowHandler() + flow.hass = hass + + # Test show form when home assistant config exists and + # home is already configured, then new config is allowed + with \ + patch.object(flow, '_show_config_form', + return_value=mock_coro()) as config_form, \ + patch.object(flow, '_homeassistant_location_exists', + return_value=mock_coro(True)), \ + patch.object(config_flow, 'smhi_locations', + return_value={ + 'test': 'something', 'name_exist': 'config' + }): + await flow.async_step_user() + assert len(config_form.mock_calls) == 1 + + # Test show form when home assistant config not and + # home is not configured + with \ + patch.object(flow, '_show_config_form', + return_value=mock_coro()) as config_form, \ + patch.object(flow, '_homeassistant_location_exists', + return_value=mock_coro(False)), \ + patch.object(config_flow, 'smhi_locations', + return_value={ + 'test': 'something', 'name_exist': 'config' + }): + + await flow.async_step_user() + assert len(config_form.mock_calls) == 1 + + +async def test_flow_show_form_name_exists() -> None: + """Test show form if name already exists. + + Test when the form should show when no configurations exists + """ + hass = Mock() + flow = config_flow.SmhiFlowHandler() + flow.hass = hass + test_data = {'name': 'home', CONF_LONGITUDE: '0', CONF_LATITUDE: '0'} + # Test show form when home assistant config exists and + # home is already configured, then new config is allowed + with \ + patch.object(flow, '_show_config_form', + return_value=mock_coro()) as config_form, \ + patch.object(flow, '_name_in_configuration_exists', + return_value=True), \ + patch.object(config_flow, 'smhi_locations', + return_value={ + 'test': 'something', 'name_exist': 'config' + }), \ + patch.object(flow, '_check_location', + return_value=mock_coro(True)): + + await flow.async_step_user(user_input=test_data) + + assert len(config_form.mock_calls) == 1 + assert len(flow._errors) == 1 + + +async def test_flow_entry_created_from_user_input() -> None: + """Test that create data from user input. + + Test when the form should show when no configurations exists + """ + hass = Mock() + flow = config_flow.SmhiFlowHandler() + flow.hass = hass + + test_data = {'name': 'home', CONF_LONGITUDE: '0', CONF_LATITUDE: '0'} + + # Test that entry created when user_input name not exists + with \ + patch.object(flow, '_show_config_form', + return_value=mock_coro()) as config_form, \ + patch.object(flow, '_name_in_configuration_exists', + return_value=False), \ + patch.object(flow, '_homeassistant_location_exists', + return_value=mock_coro(False)), \ + patch.object(config_flow, 'smhi_locations', + return_value={ + 'test': 'something', 'name_exist': 'config' + }), \ + patch.object(flow, '_check_location', + return_value=mock_coro(True)): + + result = await flow.async_step_user(user_input=test_data) + + assert result['type'] == 'create_entry' + assert result['data'] == test_data + assert not config_form.mock_calls + + +async def test_flow_entry_created_user_input_faulty() -> None: + """Test that create data from user input and are faulty. + + Test when the form should show when user puts faulty location + in the config gui. Then the form should show with error + """ + hass = Mock() + flow = config_flow.SmhiFlowHandler() + flow.hass = hass + + test_data = {'name': 'home', CONF_LONGITUDE: '0', CONF_LATITUDE: '0'} + + # Test that entry created when user_input name not exists + with \ + patch.object(flow, '_check_location', + return_value=mock_coro(True)), \ + patch.object(flow, '_show_config_form', + return_value=mock_coro()) as config_form, \ + patch.object(flow, '_name_in_configuration_exists', + return_value=False), \ + patch.object(flow, '_homeassistant_location_exists', + return_value=mock_coro(False)), \ + patch.object(config_flow, 'smhi_locations', + return_value={ + 'test': 'something', 'name_exist': 'config' + }), \ + patch.object(flow, '_check_location', + return_value=mock_coro(False)): + + await flow.async_step_user(user_input=test_data) + + assert len(config_form.mock_calls) == 1 + assert len(flow._errors) == 1 + + +async def test_check_location_correct() -> None: + """Test check location when correct input.""" + hass = Mock() + flow = config_flow.SmhiFlowHandler() + flow.hass = hass + + with \ + patch.object(config_flow.aiohttp_client, 'async_get_clientsession'),\ + patch.object(SmhiApi, 'async_get_forecast', + return_value=mock_coro()): + + assert await flow._check_location('58', '17') is True + + +async def test_check_location_faulty() -> None: + """Test check location when faulty input.""" + hass = Mock() + flow = config_flow.SmhiFlowHandler() + flow.hass = hass + + with \ + patch.object(config_flow.aiohttp_client, + 'async_get_clientsession'), \ + patch.object(SmhiApi, 'async_get_forecast', + side_effect=SmhiForecastException()): + + assert await flow._check_location('58', '17') is False diff --git a/tests/components/smhi/test_init.py b/tests/components/smhi/test_init.py new file mode 100644 index 00000000000..2b0dbaabafd --- /dev/null +++ b/tests/components/smhi/test_init.py @@ -0,0 +1,39 @@ +"""Test SMHI component setup process.""" +from unittest.mock import Mock + +from homeassistant.components import smhi + +from .common import AsyncMock + +TEST_CONFIG = { + "config": { + "name": "0123456789ABCDEF", + "longitude": "62.0022", + "latitude": "17.0022" + } +} + + +async def test_setup_always_return_true() -> None: + """Test async_setup always returns True.""" + hass = Mock() + # Returns true with empty config + assert await smhi.async_setup(hass, {}) is True + + # Returns true with a config provided + assert await smhi.async_setup(hass, TEST_CONFIG) is True + + +async def test_forward_async_setup_entry() -> None: + """Test that it will forward setup entry.""" + hass = Mock() + + assert await smhi.async_setup_entry(hass, {}) is True + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1 + + +async def test_forward_async_unload_entry() -> None: + """Test that it will forward unload entry.""" + hass = AsyncMock() + assert await smhi.async_unload_entry(hass, {}) is True + assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 1 diff --git a/tests/components/switch/test_flux.py b/tests/components/switch/test_flux.py index 69e65e24659..cb5207adb3e 100644 --- a/tests/components/switch/test_flux.py +++ b/tests/components/switch/test_flux.py @@ -183,22 +183,23 @@ class TestSwitchFlux(unittest.TestCase): return sunrise_time return sunset_time - with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id] - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.components.switch.flux.dt_now', + return_value=test_time), \ + patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id] + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 173) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.439, 0.37]) @@ -229,23 +230,24 @@ class TestSwitchFlux(unittest.TestCase): return sunrise_time return sunset_time - with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'stop_time': '22:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.components.switch.flux.dt_now', + return_value=test_time), \ + patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '22:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 146) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.506, 0.385]) @@ -322,24 +324,25 @@ class TestSwitchFlux(unittest.TestCase): return sunrise_time return sunset_time - with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'start_time': '6:00', - 'stop_time': '23:30' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.components.switch.flux.dt_now', + return_value=test_time), \ + patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'start_time': '6:00', + 'stop_time': '23:30' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 147) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.504, 0.385]) @@ -372,23 +375,24 @@ class TestSwitchFlux(unittest.TestCase): return sunrise_time return sunset_time - with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'stop_time': '01:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.components.switch.flux.dt_now', + return_value=test_time), \ + patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 112) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.606, 0.379]) @@ -423,23 +427,24 @@ class TestSwitchFlux(unittest.TestCase): return sunrise_time return sunset_time - with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'stop_time': '01:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.components.switch.flux.dt_now', + return_value=test_time), \ + patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 173) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.439, 0.37]) @@ -523,23 +528,24 @@ class TestSwitchFlux(unittest.TestCase): return sunrise_time return sunset_time - with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'stop_time': '01:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.components.switch.flux.dt_now', + return_value=test_time), \ + patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 114) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.601, 0.382]) @@ -573,23 +579,24 @@ class TestSwitchFlux(unittest.TestCase): return sunrise_time return sunset_time - with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'stop_time': '01:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.components.switch.flux.dt_now', + return_value=test_time), \ + patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 112) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.606, 0.379]) @@ -620,25 +627,26 @@ class TestSwitchFlux(unittest.TestCase): return sunrise_time return sunset_time - with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'start_colortemp': '1000', - 'stop_colortemp': '6000', - 'stop_time': '22:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.components.switch.flux.dt_now', + return_value=test_time), \ + patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'start_colortemp': '1000', + 'stop_colortemp': '6000', + 'stop_time': '22:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 159) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.469, 0.378]) @@ -669,24 +677,25 @@ class TestSwitchFlux(unittest.TestCase): return sunrise_time return sunset_time - with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'brightness': 255, - 'stop_time': '22:00' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.components.switch.flux.dt_now', + return_value=test_time), \ + patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'brightness': 255, + 'stop_time': '22:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 255) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.506, 0.385]) @@ -731,24 +740,25 @@ class TestSwitchFlux(unittest.TestCase): print('sunset {}'.format(sunset_time)) return sunset_time - with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id, - dev2.entity_id, - dev3.entity_id] - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.components.switch.flux.dt_now', + return_value=test_time), \ + patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id, + dev2.entity_id, + dev3.entity_id] + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 163) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.46, 0.376]) @@ -783,23 +793,24 @@ class TestSwitchFlux(unittest.TestCase): return sunrise_time return sunset_time - with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'mode': 'mired' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.components.switch.flux.dt_now', + return_value=test_time), \ + patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'mode': 'mired' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_COLOR_TEMP], 269) @@ -827,23 +838,24 @@ class TestSwitchFlux(unittest.TestCase): return sunrise_time return sunset_time - with patch('homeassistant.util.dt.now', return_value=test_time): - with patch('homeassistant.helpers.sun.get_astral_event_date', - side_effect=event_date): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'flux', - 'name': 'flux', - 'lights': [dev1.entity_id], - 'mode': 'rgb' - } - }) - turn_on_calls = mock_service( - self.hass, light.DOMAIN, SERVICE_TURN_ON) - common.turn_on(self.hass, 'switch.flux') - self.hass.block_till_done() - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + with patch('homeassistant.components.switch.flux.dt_now', + return_value=test_time), \ + patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'mode': 'rgb' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + common.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() call = turn_on_calls[-1] rgb = (255, 198, 152) rounded_call = tuple(map(round, call.data[light.ATTR_RGB_COLOR])) diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py index 5ad233de284..3552ec0dc2a 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/switch/test_mqtt.py @@ -1,4 +1,5 @@ """The tests for the MQTT switch platform.""" +import json import unittest from unittest.mock import patch @@ -337,3 +338,42 @@ async def test_discovery_removal_switch(hass, mqtt_mock, caplog): state = hass.states.get('switch.beer') assert state is None + + +async def test_entity_device_info_with_identifier(hass, mqtt_mock): + """Test MQTT switch device registry integration.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, 'homeassistant', {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + data = json.dumps({ + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'command_topic': 'test-command-topic', + 'device': { + 'identifiers': ['helloworld'], + 'connections': [ + ["mac", "02:5b:26:a8:dc:12"], + ], + 'manufacturer': 'Whatever', + 'name': 'Beer', + 'model': 'Glass', + 'sw_version': '0.1-beta', + }, + 'unique_id': 'veryunique' + }) + async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config', + data) + await hass.async_block_till_done() + await hass.async_block_till_done() + + device = registry.async_get_device({('mqtt', 'helloworld')}, set()) + assert device is not None + assert device.identifiers == {('mqtt', 'helloworld')} + assert device.connections == {('mac', "02:5b:26:a8:dc:12")} + assert device.manufacturer == 'Whatever' + assert device.name == 'Beer' + assert device.model == 'Glass' + assert device.sw_version == '0.1-beta' diff --git a/tests/components/switch/test_unifi.py b/tests/components/switch/test_unifi.py new file mode 100644 index 00000000000..f50bda34883 --- /dev/null +++ b/tests/components/switch/test_unifi.py @@ -0,0 +1,345 @@ +"""UniFi POE control platform tests.""" +from collections import deque +from unittest.mock import Mock + +import pytest + +import aiounifi +from aiounifi.clients import Clients +from aiounifi.devices import Devices + +from homeassistant import config_entries +from homeassistant.components import unifi +from homeassistant.setup import async_setup_component + +import homeassistant.components.switch as switch + +from tests.common import mock_coro + +CLIENT_1 = { + 'hostname': 'client_1', + 'ip': '10.0.0.1', + 'is_wired': True, + 'mac': '00:00:00:00:00:01', + 'name': 'POE Client 1', + 'oui': 'Producer', + 'sw_mac': '00:00:00:00:01:01', + 'sw_port': 1, + 'wired-rx_bytes': 1234000000, + 'wired-tx_bytes': 5678000000 +} +CLIENT_2 = { + 'hostname': 'client_2', + 'ip': '10.0.0.2', + 'is_wired': True, + 'mac': '00:00:00:00:00:02', + 'name': 'POE Client 2', + 'oui': 'Producer', + 'sw_mac': '00:00:00:00:01:01', + 'sw_port': 2, + 'wired-rx_bytes': 1234000000, + 'wired-tx_bytes': 5678000000 +} +CLIENT_3 = { + 'hostname': 'client_3', + 'ip': '10.0.0.3', + 'is_wired': True, + 'mac': '00:00:00:00:00:03', + 'name': 'Non-POE Client 3', + 'oui': 'Producer', + 'sw_mac': '00:00:00:00:01:01', + 'sw_port': 3, + 'wired-rx_bytes': 1234000000, + 'wired-tx_bytes': 5678000000 +} +CLIENT_4 = { + 'hostname': 'client_4', + 'ip': '10.0.0.4', + 'is_wired': True, + 'mac': '00:00:00:00:00:04', + 'name': 'Non-POE Client 4', + 'oui': 'Producer', + 'sw_mac': '00:00:00:00:01:01', + 'sw_port': 4, + 'wired-rx_bytes': 1234000000, + 'wired-tx_bytes': 5678000000 +} +POE_SWITCH_CLIENTS = [ + { + 'hostname': 'client_1', + 'ip': '10.0.0.1', + 'is_wired': True, + 'mac': '00:00:00:00:00:01', + 'name': 'POE Client 1', + 'oui': 'Producer', + 'sw_mac': '00:00:00:00:01:01', + 'sw_port': 1, + 'wired-rx_bytes': 1234000000, + 'wired-tx_bytes': 5678000000 + }, + { + 'hostname': 'client_2', + 'ip': '10.0.0.2', + 'is_wired': True, + 'mac': '00:00:00:00:00:02', + 'name': 'POE Client 2', + 'oui': 'Producer', + 'sw_mac': '00:00:00:00:01:01', + 'sw_port': 1, + 'wired-rx_bytes': 1234000000, + 'wired-tx_bytes': 5678000000 + } +] + +DEVICE_1 = { + 'device_id': 'mock-id', + 'ip': '10.0.1.1', + 'mac': '00:00:00:00:01:01', + 'type': 'usw', + 'name': 'mock-name', + 'portconf_id': '', + 'port_table': [ + { + 'media': 'GE', + 'name': 'Port 1', + 'port_idx': 1, + 'poe_class': 'Class 4', + 'poe_enable': True, + 'poe_mode': 'auto', + 'poe_power': '2.56', + 'poe_voltage': '53.40', + 'portconf_id': '1a1', + 'port_poe': True, + 'up': True + }, + { + 'media': 'GE', + 'name': 'Port 2', + 'port_idx': 2, + 'poe_class': 'Class 4', + 'poe_enable': True, + 'poe_mode': 'auto', + 'poe_power': '2.56', + 'poe_voltage': '53.40', + 'portconf_id': '1a2', + 'port_poe': True, + 'up': True + }, + { + 'media': 'GE', + 'name': 'Port 3', + 'port_idx': 3, + 'poe_class': 'Unknown', + 'poe_enable': False, + 'poe_mode': 'off', + 'poe_power': '0.00', + 'poe_voltage': '0.00', + 'portconf_id': '1a3', + 'port_poe': False, + 'up': True + }, + { + 'media': 'GE', + 'name': 'Port 4', + 'port_idx': 4, + 'poe_class': 'Unknown', + 'poe_enable': False, + 'poe_mode': 'auto', + 'poe_power': '0.00', + 'poe_voltage': '0.00', + 'portconf_id': '1a4', + 'port_poe': True, + 'up': True + } + ] +} + +CONTROLLER_DATA = { + unifi.CONF_HOST: 'mock-host', + unifi.CONF_USERNAME: 'mock-user', + unifi.CONF_PASSWORD: 'mock-pswd', + unifi.CONF_PORT: 1234, + unifi.CONF_SITE_ID: 'mock-site', + unifi.CONF_VERIFY_SSL: True +} + +ENTRY_CONFIG = { + unifi.CONF_CONTROLLER: CONTROLLER_DATA, + unifi.CONF_POE_CONTROL: True +} + +CONTROLLER_ID = unifi.CONTROLLER_ID.format(host='mock-host', site='mock-site') + + +@pytest.fixture +def mock_controller(hass): + """Mock a UniFi Controller.""" + controller = Mock( + available=True, + api=Mock(), + spec=unifi.UniFiController + ) + controller.mock_requests = [] + + controller.mock_client_responses = deque() + controller.mock_device_responses = deque() + + async def mock_request(method, path, **kwargs): + kwargs['method'] = method + kwargs['path'] = path + controller.mock_requests.append(kwargs) + if path == 's/{site}/stat/sta': + return controller.mock_client_responses.popleft() + if path == 's/{site}/stat/device': + return controller.mock_device_responses.popleft() + return None + + controller.api.clients = Clients({}, mock_request) + controller.api.devices = Devices({}, mock_request) + + return controller + + +async def setup_controller(hass, mock_controller): + """Load the UniFi switch platform with the provided controller.""" + hass.config.components.add(unifi.DOMAIN) + hass.data[unifi.DOMAIN] = {CONTROLLER_ID: mock_controller} + config_entry = config_entries.ConfigEntry( + 1, unifi.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test', + config_entries.CONN_CLASS_LOCAL_POLL) + await hass.config_entries.async_forward_entry_setup(config_entry, 'switch') + # To flush out the service call to update the group + await hass.async_block_till_done() + + +async def test_platform_manually_configured(hass): + """Test that we do not discover anything or try to set up a bridge.""" + assert await async_setup_component(hass, switch.DOMAIN, { + 'switch': { + 'platform': 'unifi' + } + }) is True + assert unifi.DOMAIN not in hass.data + + +async def test_no_clients(hass, mock_controller): + """Test the update_clients function when no clients are found.""" + mock_controller.mock_client_responses.append({}) + await setup_controller(hass, mock_controller) + assert len(mock_controller.mock_requests) == 2 + assert not hass.states.async_all() + + +async def test_switches(hass, mock_controller): + """Test the update_items function with some lights.""" + mock_controller.mock_client_responses.append([CLIENT_1, CLIENT_4]) + mock_controller.mock_device_responses.append([DEVICE_1]) + await setup_controller(hass, mock_controller) + assert len(mock_controller.mock_requests) == 2 + # 1 All Lights group, 2 lights + assert len(hass.states.async_all()) == 2 + + switch_1 = hass.states.get('switch.client_1') + assert switch_1 is not None + assert switch_1.state == 'on' + assert switch_1.attributes['power'] == '2.56' + assert switch_1.attributes['received'] == 1234 + assert switch_1.attributes['sent'] == 5678 + assert switch_1.attributes['switch'] == '00:00:00:00:01:01' + assert switch_1.attributes['port'] == 1 + assert switch_1.attributes['poe_mode'] == 'auto' + + switch = hass.states.get('switch.client_4') + assert switch is None + + +async def test_new_client_discovered(hass, mock_controller): + """Test if 2nd update has a new client.""" + mock_controller.mock_client_responses.append([CLIENT_1]) + mock_controller.mock_device_responses.append([DEVICE_1]) + + await setup_controller(hass, mock_controller) + assert len(mock_controller.mock_requests) == 2 + assert len(hass.states.async_all()) == 2 + + mock_controller.mock_client_responses.append([CLIENT_1, CLIENT_2]) + mock_controller.mock_device_responses.append([DEVICE_1]) + + # Calling a service will trigger the updates to run + await hass.services.async_call('switch', 'turn_off', { + 'entity_id': 'switch.client_1' + }, blocking=True) + # 2x light update, 1 turn on request + assert len(mock_controller.mock_requests) == 5 + assert len(hass.states.async_all()) == 3 + + switch = hass.states.get('switch.client_2') + assert switch is not None + assert switch.state == 'on' + + +async def test_failed_update_successful_login(hass, mock_controller): + """Running update can login when requested.""" + mock_controller.available = False + mock_controller.api.clients.update = Mock() + mock_controller.api.clients.update.side_effect = aiounifi.LoginRequired + mock_controller.api.login = Mock() + mock_controller.api.login.return_value = mock_coro() + + await setup_controller(hass, mock_controller) + assert len(mock_controller.mock_requests) == 0 + + assert mock_controller.available is True + + +async def test_failed_update_failed_login(hass, mock_controller): + """Running update can handle a failed login.""" + mock_controller.api.clients.update = Mock() + mock_controller.api.clients.update.side_effect = aiounifi.LoginRequired + mock_controller.api.login = Mock() + mock_controller.api.login.side_effect = aiounifi.AiounifiException + + await setup_controller(hass, mock_controller) + assert len(mock_controller.mock_requests) == 0 + + assert mock_controller.available is False + + +async def test_failed_update_unreachable_controller(hass, mock_controller): + """Running update can handle a unreachable controller.""" + mock_controller.mock_client_responses.append([CLIENT_1, CLIENT_2]) + mock_controller.mock_device_responses.append([DEVICE_1]) + + await setup_controller(hass, mock_controller) + + mock_controller.api.clients.update = Mock() + mock_controller.api.clients.update.side_effect = aiounifi.AiounifiException + + # Calling a service will trigger the updates to run + await hass.services.async_call('switch', 'turn_off', { + 'entity_id': 'switch.client_1' + }, blocking=True) + # 2x light update, 1 turn on request + assert len(mock_controller.mock_requests) == 3 + assert len(hass.states.async_all()) == 3 + + assert mock_controller.available is False + + +async def test_ignore_multiple_poe_clients_on_same_port(hass, mock_controller): + """Ignore when there are multiple POE driven clients on same port. + + If there is a non-UniFi switch powered by POE, + clients will be transparently marked as having POE as well. + """ + mock_controller.mock_client_responses.append(POE_SWITCH_CLIENTS) + mock_controller.mock_device_responses.append([DEVICE_1]) + await setup_controller(hass, mock_controller) + assert len(mock_controller.mock_requests) == 2 + # 1 All Lights group, 2 lights + assert len(hass.states.async_all()) == 0 + + switch_1 = hass.states.get('switch.client_1') + switch_2 = hass.states.get('switch.client_2') + assert switch_1 is None + assert switch_2 is None diff --git a/tests/components/test_dyson.py b/tests/components/test_dyson.py index 19c39754eb2..0352551aec9 100644 --- a/tests/components/test_dyson.py +++ b/tests/components/test_dyson.py @@ -87,7 +87,7 @@ class DysonTest(unittest.TestCase): self.assertEqual(mocked_login.call_count, 1) self.assertEqual(mocked_devices.call_count, 1) self.assertEqual(len(self.hass.data[dyson.DYSON_DEVICES]), 1) - self.assertEqual(mocked_discovery.call_count, 3) + self.assertEqual(mocked_discovery.call_count, 4) @mock.patch('libpurecoollink.dyson.DysonAccount.devices', return_value=[_get_dyson_account_device_not_available()]) @@ -172,7 +172,7 @@ class DysonTest(unittest.TestCase): self.assertEqual(mocked_login.call_count, 1) self.assertEqual(mocked_devices.call_count, 1) self.assertEqual(len(self.hass.data[dyson.DYSON_DEVICES]), 1) - self.assertEqual(mocked_discovery.call_count, 3) + self.assertEqual(mocked_discovery.call_count, 4) @mock.patch('libpurecoollink.dyson.DysonAccount.devices', return_value=[_get_dyson_account_device_not_available()]) diff --git a/tests/components/test_init.py b/tests/components/test_init.py index 68396f5abcb..b9152bbdd6a 100644 --- a/tests/components/test_init.py +++ b/tests/components/test_init.py @@ -355,3 +355,17 @@ async def test_turn_on_to_not_block_for_domains_without_service(hass): 'light', 'turn_on', {'entity_id': ['light.bla', 'light.test']}, True) assert mock_call.call_args_list[1][0] == ( 'sensor', 'turn_on', {'entity_id': ['sensor.bla']}, False) + + +async def test_entity_update(hass): + """Test being able to call entity update.""" + await comps.async_setup(hass, {}) + + with patch('homeassistant.helpers.entity_component.async_update_entity', + return_value=mock_coro()) as mock_update: + await hass.services.async_call('homeassistant', 'update_entity', { + 'entity_id': 'light.kitchen' + }, blocking=True) + + assert len(mock_update.mock_calls) == 1 + assert mock_update.mock_calls[0][1][1] == 'light.kitchen' diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 3bb3ae57c68..1100a16b381 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -7,11 +7,15 @@ import unittest from homeassistant.components import sun import homeassistant.core as ha from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_SERVICE, EVENT_STATE_CHANGED, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ATTR_HIDDEN, STATE_NOT_HOME, STATE_ON, STATE_OFF) import homeassistant.util.dt as dt_util from homeassistant.components import logbook, recorder from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME +from homeassistant.components.homekit.const import ( + ATTR_DISPLAY_NAME, ATTR_VALUE, DOMAIN as DOMAIN_HOMEKIT, + EVENT_HOMEKIT_CHANGED) from homeassistant.setup import setup_component, async_setup_component from tests.common import ( @@ -684,3 +688,31 @@ async def test_humanify_alexa_event(hass): assert event3['message'] == \ 'send command Alexa.PowerController/TurnOn for light.non_existing' assert event3['entity_id'] == 'light.non_existing' + + +async def test_humanify_homekit_changed_event(hass): + """Test humanifying HomeKit changed event.""" + event1, event2 = list(logbook.humanify(hass, [ + ha.Event(EVENT_HOMEKIT_CHANGED, { + ATTR_ENTITY_ID: 'lock.front_door', + ATTR_DISPLAY_NAME: 'Front Door', + ATTR_SERVICE: 'lock', + }), + ha.Event(EVENT_HOMEKIT_CHANGED, { + ATTR_ENTITY_ID: 'cover.window', + ATTR_DISPLAY_NAME: 'Window', + ATTR_SERVICE: 'set_cover_position', + ATTR_VALUE: 75, + }), + ])) + + assert event1['name'] == 'HomeKit' + assert event1['domain'] == DOMAIN_HOMEKIT + assert event1['message'] == 'send command lock for Front Door' + assert event1['entity_id'] == 'lock.front_door' + + assert event2['name'] == 'HomeKit' + assert event1['domain'] == DOMAIN_HOMEKIT + assert event2['message'] == \ + 'send command set_cover_position to 75 for Window' + assert event2['entity_id'] == 'cover.window' diff --git a/tests/components/unifi/__init__.py b/tests/components/unifi/__init__.py new file mode 100644 index 00000000000..e75b2778d2b --- /dev/null +++ b/tests/components/unifi/__init__.py @@ -0,0 +1 @@ +"""Tests for the UniFi component.""" diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py new file mode 100644 index 00000000000..b3b222d902a --- /dev/null +++ b/tests/components/unifi/test_controller.py @@ -0,0 +1,266 @@ +"""Test UniFi Controller.""" +from unittest.mock import Mock, patch + +from homeassistant.components import unifi +from homeassistant.components.unifi import controller, errors + +from tests.common import mock_coro + +CONTROLLER_DATA = { + unifi.CONF_HOST: '1.2.3.4', + unifi.CONF_USERNAME: 'username', + unifi.CONF_PASSWORD: 'password', + unifi.CONF_PORT: 1234, + unifi.CONF_SITE_ID: 'site', + unifi.CONF_VERIFY_SSL: True +} + +ENTRY_CONFIG = { + unifi.CONF_CONTROLLER: CONTROLLER_DATA, + unifi.CONF_POE_CONTROL: True + } + + +async def test_controller_setup(): + """Successful setup.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + api = Mock() + api.initialize.return_value = mock_coro(True) + + unifi_controller = controller.UniFiController(hass, entry) + + with patch.object(controller, 'get_controller', + return_value=mock_coro(api)): + assert await unifi_controller.async_setup() is True + + assert unifi_controller.api is api + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1 + assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \ + (entry, 'switch') + + +async def test_controller_host(): + """Config entry host and controller host are the same.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + + unifi_controller = controller.UniFiController(hass, entry) + + assert unifi_controller.host == '1.2.3.4' + + +async def test_controller_mac(): + """Test that it is possible to identify controller mac.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + client = Mock() + client.ip = '1.2.3.4' + client.mac = '00:11:22:33:44:55' + api = Mock() + api.initialize.return_value = mock_coro(True) + api.clients = {'client1': client} + + unifi_controller = controller.UniFiController(hass, entry) + + with patch.object(controller, 'get_controller', + return_value=mock_coro(api)): + assert await unifi_controller.async_setup() is True + + assert unifi_controller.mac == '00:11:22:33:44:55' + + +async def test_controller_no_mac(): + """Test that it works to not find the controllers mac.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + client = Mock() + client.ip = '5.6.7.8' + api = Mock() + api.initialize.return_value = mock_coro(True) + api.clients = {'client1': client} + + unifi_controller = controller.UniFiController(hass, entry) + + with patch.object(controller, 'get_controller', + return_value=mock_coro(api)): + assert await unifi_controller.async_setup() is True + + assert unifi_controller.mac is None + + +async def test_controller_not_accessible(): + """Retry to login gets scheduled when connection fails.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + api = Mock() + api.initialize.return_value = mock_coro(True) + + unifi_controller = controller.UniFiController(hass, entry) + + with patch.object(controller, 'get_controller', + side_effect=errors.CannotConnect): + assert await unifi_controller.async_setup() is False + + assert len(hass.helpers.event.async_call_later.mock_calls) == 1 + # Assert we are going to wait 2 seconds + assert hass.helpers.event.async_call_later.mock_calls[0][1][0] == 2 + + +async def test_controller_unknown_error(): + """Unknown errors are handled.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + api = Mock() + api.initialize.return_value = mock_coro(True) + + unifi_controller = controller.UniFiController(hass, entry) + + with patch.object(controller, 'get_controller', side_effect=Exception): + assert await unifi_controller.async_setup() is False + + assert not hass.helpers.event.async_call_later.mock_calls + + +async def test_reset_cancels_retry_setup(): + """Resetting a controller while we're waiting to retry setup.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + + unifi_controller = controller.UniFiController(hass, entry) + + with patch.object(controller, 'get_controller', + side_effect=errors.CannotConnect): + assert await unifi_controller.async_setup() is False + + mock_call_later = hass.helpers.event.async_call_later + + assert len(mock_call_later.mock_calls) == 1 + + assert await unifi_controller.async_reset() + + assert len(mock_call_later.mock_calls) == 2 + assert len(mock_call_later.return_value.mock_calls) == 1 + + +async def test_reset_if_entry_had_wrong_auth(): + """Calling reset when the entry contains wrong auth.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + + unifi_controller = controller.UniFiController(hass, entry) + + with patch.object(controller, 'get_controller', + side_effect=errors.AuthenticationRequired): + assert await unifi_controller.async_setup() is False + + assert not hass.async_add_job.mock_calls + + assert await unifi_controller.async_reset() + + +async def test_reset_unloads_entry_if_setup(): + """Calling reset when the entry has been setup.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + api = Mock() + api.initialize.return_value = mock_coro(True) + + unifi_controller = controller.UniFiController(hass, entry) + + with patch.object(controller, 'get_controller', + return_value=mock_coro(api)): + assert await unifi_controller.async_setup() is True + + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1 + + hass.config_entries.async_forward_entry_unload.return_value = \ + mock_coro(True) + assert await unifi_controller.async_reset() + + assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 1 + + +async def test_reset_unloads_entry_without_poe_control(): + """Calling reset while the entry has been setup.""" + hass = Mock() + entry = Mock() + entry.data = dict(ENTRY_CONFIG) + entry.data[unifi.CONF_POE_CONTROL] = False + api = Mock() + api.initialize.return_value = mock_coro(True) + + unifi_controller = controller.UniFiController(hass, entry) + + with patch.object(controller, 'get_controller', + return_value=mock_coro(api)): + assert await unifi_controller.async_setup() is True + + assert not hass.config_entries.async_forward_entry_setup.mock_calls + + hass.config_entries.async_forward_entry_unload.return_value = \ + mock_coro(True) + assert await unifi_controller.async_reset() + + assert not hass.config_entries.async_forward_entry_unload.mock_calls + + +async def test_get_controller(hass): + """Successful call.""" + with patch('aiounifi.Controller.login', return_value=mock_coro()): + assert await controller.get_controller(hass, **CONTROLLER_DATA) + + +async def test_get_controller_verify_ssl_false(hass): + """Successful call with verify ssl set to false.""" + controller_data = dict(CONTROLLER_DATA) + controller_data[unifi.CONF_VERIFY_SSL] = False + with patch('aiounifi.Controller.login', return_value=mock_coro()): + assert await controller.get_controller(hass, **controller_data) + + +async def test_get_controller_login_failed(hass): + """Check that get_controller can handle a failed login.""" + import aiounifi + result = None + with patch('aiounifi.Controller.login', side_effect=aiounifi.Unauthorized): + try: + result = await controller.get_controller(hass, **CONTROLLER_DATA) + except errors.AuthenticationRequired: + pass + assert result is None + + +async def test_get_controller_controller_unavailable(hass): + """Check that get_controller can handle controller being unavailable.""" + import aiounifi + result = None + with patch('aiounifi.Controller.login', + side_effect=aiounifi.RequestError): + try: + result = await controller.get_controller(hass, **CONTROLLER_DATA) + except errors.CannotConnect: + pass + assert result is None + + +async def test_get_controller_unknown_error(hass): + """Check that get_controller can handle unkown errors.""" + import aiounifi + result = None + with patch('aiounifi.Controller.login', + side_effect=aiounifi.AiounifiException): + try: + result = await controller.get_controller(hass, **CONTROLLER_DATA) + except errors.AuthenticationRequired: + pass + assert result is None diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py new file mode 100644 index 00000000000..400dd3fd93e --- /dev/null +++ b/tests/components/unifi/test_init.py @@ -0,0 +1,330 @@ +"""Test UniFi setup process.""" +from unittest.mock import Mock, patch + +from homeassistant.components import unifi +from homeassistant.setup import async_setup_component + +from tests.common import mock_coro, MockConfigEntry + + +async def test_setup_with_no_config(hass): + """Test that we do not discover anything or try to set up a bridge.""" + assert await async_setup_component(hass, unifi.DOMAIN, {}) is True + assert unifi.DOMAIN not in hass.data + + +async def test_successful_config_entry(hass): + """Test that configured options for a host are loaded via config entry.""" + entry = MockConfigEntry(domain=unifi.DOMAIN, data={ + 'controller': { + 'host': '0.0.0.0', + 'username': 'user', + 'password': 'pass', + 'port': 80, + 'site': 'default', + 'verify_ssl': True + }, + 'poe_control': True + }) + entry.add_to_hass(hass) + mock_registry = Mock() + with patch.object(unifi, 'UniFiController') as mock_controller, \ + patch('homeassistant.helpers.device_registry.async_get_registry', + return_value=mock_coro(mock_registry)): + mock_controller.return_value.async_setup.return_value = mock_coro(True) + mock_controller.return_value.mac = '00:11:22:33:44:55' + assert await unifi.async_setup_entry(hass, entry) is True + + assert len(mock_controller.mock_calls) == 2 + p_hass, p_entry = mock_controller.mock_calls[0][1] + + assert p_hass is hass + assert p_entry is entry + + assert len(mock_registry.mock_calls) == 1 + assert mock_registry.mock_calls[0][2] == { + 'config_entry_id': entry.entry_id, + 'connections': { + ('mac', '00:11:22:33:44:55') + }, + 'manufacturer': 'Ubiquiti', + 'model': "UniFi Controller", + 'name': "UniFi Controller", + } + + +async def test_controller_fail_setup(hass): + """Test that configured options for a host are loaded via config entry.""" + entry = MockConfigEntry(domain=unifi.DOMAIN, data={ + 'controller': { + 'host': '0.0.0.0', + 'username': 'user', + 'password': 'pass', + 'port': 80, + 'site': 'default', + 'verify_ssl': True + }, + 'poe_control': True + }) + entry.add_to_hass(hass) + + with patch.object(unifi, 'UniFiController') as mock_cntrlr: + mock_cntrlr.return_value.async_setup.return_value = mock_coro(False) + assert await unifi.async_setup_entry(hass, entry) is False + + controller_id = unifi.CONTROLLER_ID.format( + host='0.0.0.0', site='default' + ) + assert controller_id not in hass.data[unifi.DOMAIN] + + +async def test_controller_no_mac(hass): + """Test that configured options for a host are loaded via config entry.""" + entry = MockConfigEntry(domain=unifi.DOMAIN, data={ + 'controller': { + 'host': '0.0.0.0', + 'username': 'user', + 'password': 'pass', + 'port': 80, + 'site': 'default', + 'verify_ssl': True + }, + 'poe_control': True + }) + entry.add_to_hass(hass) + mock_registry = Mock() + with patch.object(unifi, 'UniFiController') as mock_controller, \ + patch('homeassistant.helpers.device_registry.async_get_registry', + return_value=mock_coro(mock_registry)): + mock_controller.return_value.async_setup.return_value = mock_coro(True) + mock_controller.return_value.mac = None + assert await unifi.async_setup_entry(hass, entry) is True + + assert len(mock_controller.mock_calls) == 2 + + assert len(mock_registry.mock_calls) == 0 + + +async def test_unload_entry(hass): + """Test being able to unload an entry.""" + entry = MockConfigEntry(domain=unifi.DOMAIN, data={ + 'controller': { + 'host': '0.0.0.0', + 'username': 'user', + 'password': 'pass', + 'port': 80, + 'site': 'default', + 'verify_ssl': True + }, + 'poe_control': True + }) + entry.add_to_hass(hass) + + with patch.object(unifi, 'UniFiController') as mock_controller, \ + patch('homeassistant.helpers.device_registry.async_get_registry', + return_value=mock_coro(Mock())): + mock_controller.return_value.async_setup.return_value = mock_coro(True) + mock_controller.return_value.mac = '00:11:22:33:44:55' + assert await unifi.async_setup_entry(hass, entry) is True + + assert len(mock_controller.return_value.mock_calls) == 1 + + mock_controller.return_value.async_reset.return_value = mock_coro(True) + assert await unifi.async_unload_entry(hass, entry) + assert len(mock_controller.return_value.async_reset.mock_calls) == 1 + assert hass.data[unifi.DOMAIN] == {} + + +async def test_flow_works(hass, aioclient_mock): + """Test config flow.""" + flow = unifi.UnifiFlowHandler() + flow.hass = hass + + with patch('aiounifi.Controller') as mock_controller: + def mock_constructor(host, username, password, port, site, websession): + """Fake the controller constructor.""" + mock_controller.host = host + mock_controller.username = username + mock_controller.password = password + mock_controller.port = port + mock_controller.site = site + return mock_controller + + mock_controller.side_effect = mock_constructor + mock_controller.login.return_value = mock_coro() + mock_controller.sites.return_value = mock_coro({ + 'site1': {'name': 'default', 'role': 'admin', 'desc': 'site name'} + }) + + await flow.async_step_user(user_input={ + unifi.CONF_HOST: '1.2.3.4', + unifi.CONF_USERNAME: 'username', + unifi.CONF_PASSWORD: 'password', + unifi.CONF_PORT: 1234, + unifi.CONF_VERIFY_SSL: True + }) + + result = await flow.async_step_site(user_input={}) + + assert mock_controller.host == '1.2.3.4' + assert len(mock_controller.login.mock_calls) == 1 + assert len(mock_controller.sites.mock_calls) == 1 + + assert result['type'] == 'create_entry' + assert result['title'] == 'site name' + assert result['data'] == { + unifi.CONF_CONTROLLER: { + unifi.CONF_HOST: '1.2.3.4', + unifi.CONF_USERNAME: 'username', + unifi.CONF_PASSWORD: 'password', + unifi.CONF_PORT: 1234, + unifi.CONF_SITE_ID: 'default', + unifi.CONF_VERIFY_SSL: True + }, + unifi.CONF_POE_CONTROL: True + } + + +async def test_controller_multiple_sites(hass): + """Test config flow.""" + flow = unifi.UnifiFlowHandler() + flow.hass = hass + + flow.config = { + unifi.CONF_HOST: '1.2.3.4', + unifi.CONF_USERNAME: 'username', + unifi.CONF_PASSWORD: 'password', + } + flow.sites = { + 'site1': { + 'name': 'default', 'role': 'admin', 'desc': 'site name' + }, + 'site2': { + 'name': 'site2', 'role': 'admin', 'desc': 'site2 name' + } + } + + result = await flow.async_step_site() + + assert result['type'] == 'form' + assert result['step_id'] == 'site' + + assert result['data_schema']({'site': 'site name'}) + assert result['data_schema']({'site': 'site2 name'}) + + +async def test_controller_site_already_configured(hass): + """Test config flow.""" + flow = unifi.UnifiFlowHandler() + flow.hass = hass + + entry = MockConfigEntry(domain=unifi.DOMAIN, data={ + 'controller': { + 'host': '1.2.3.4', + 'site': 'default', + } + }) + entry.add_to_hass(hass) + + flow.config = { + unifi.CONF_HOST: '1.2.3.4', + unifi.CONF_USERNAME: 'username', + unifi.CONF_PASSWORD: 'password', + } + flow.desc = 'site name' + flow.sites = { + 'site1': { + 'name': 'default', 'role': 'admin', 'desc': 'site name' + } + } + + result = await flow.async_step_site() + + assert result['type'] == 'abort' + + +async def test_user_permissions_low(hass, aioclient_mock): + """Test config flow.""" + flow = unifi.UnifiFlowHandler() + flow.hass = hass + + with patch('aiounifi.Controller') as mock_controller: + def mock_constructor(host, username, password, port, site, websession): + """Fake the controller constructor.""" + mock_controller.host = host + mock_controller.username = username + mock_controller.password = password + mock_controller.port = port + mock_controller.site = site + return mock_controller + + mock_controller.side_effect = mock_constructor + mock_controller.login.return_value = mock_coro() + mock_controller.sites.return_value = mock_coro({ + 'site1': {'name': 'default', 'role': 'viewer', 'desc': 'site name'} + }) + + await flow.async_step_user(user_input={ + unifi.CONF_HOST: '1.2.3.4', + unifi.CONF_USERNAME: 'username', + unifi.CONF_PASSWORD: 'password', + unifi.CONF_PORT: 1234, + unifi.CONF_VERIFY_SSL: True + }) + + result = await flow.async_step_site(user_input={}) + + assert result['type'] == 'abort' + + +async def test_user_credentials_faulty(hass, aioclient_mock): + """Test config flow.""" + flow = unifi.UnifiFlowHandler() + flow.hass = hass + + with patch.object(unifi, 'get_controller', + side_effect=unifi.errors.AuthenticationRequired): + result = await flow.async_step_user({ + unifi.CONF_HOST: '1.2.3.4', + unifi.CONF_USERNAME: 'username', + unifi.CONF_PASSWORD: 'password', + unifi.CONF_SITE_ID: 'default', + }) + + assert result['type'] == 'form' + assert result['errors'] == {'base': 'faulty_credentials'} + + +async def test_controller_is_unavailable(hass, aioclient_mock): + """Test config flow.""" + flow = unifi.UnifiFlowHandler() + flow.hass = hass + + with patch.object(unifi, 'get_controller', + side_effect=unifi.errors.CannotConnect): + result = await flow.async_step_user({ + unifi.CONF_HOST: '1.2.3.4', + unifi.CONF_USERNAME: 'username', + unifi.CONF_PASSWORD: 'password', + unifi.CONF_SITE_ID: 'default', + }) + + assert result['type'] == 'form' + assert result['errors'] == {'base': 'service_unavailable'} + + +async def test_controller_unkown_problem(hass, aioclient_mock): + """Test config flow.""" + flow = unifi.UnifiFlowHandler() + flow.hass = hass + + with patch.object(unifi, 'get_controller', + side_effect=Exception): + result = await flow.async_step_user({ + unifi.CONF_HOST: '1.2.3.4', + unifi.CONF_USERNAME: 'username', + unifi.CONF_PASSWORD: 'password', + unifi.CONF_SITE_ID: 'default', + }) + + assert result['type'] == 'abort' diff --git a/tests/components/water_heater/__init__.py b/tests/components/water_heater/__init__.py new file mode 100644 index 00000000000..673119bf16e --- /dev/null +++ b/tests/components/water_heater/__init__.py @@ -0,0 +1 @@ +"""The tests for water heater component.""" diff --git a/tests/components/water_heater/common.py b/tests/components/water_heater/common.py new file mode 100644 index 00000000000..34173e7f110 --- /dev/null +++ b/tests/components/water_heater/common.py @@ -0,0 +1,51 @@ +"""Collection of helper methods. + +All containing methods are legacy helpers that should not be used by new +components. Instead call the service directly. +""" +from homeassistant.components.water_heater import ( + _LOGGER, ATTR_AWAY_MODE, + ATTR_OPERATION_MODE, DOMAIN, SERVICE_SET_AWAY_MODE, + SERVICE_SET_TEMPERATURE, SERVICE_SET_OPERATION_MODE) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_TEMPERATURE) +from homeassistant.loader import bind_hass + + +@bind_hass +def set_away_mode(hass, away_mode, entity_id=None): + """Turn all or specified water_heater devices away mode on.""" + data = { + ATTR_AWAY_MODE: away_mode + } + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SET_AWAY_MODE, data) + + +@bind_hass +def set_temperature(hass, temperature=None, entity_id=None, + operation_mode=None): + """Set new target temperature.""" + kwargs = { + key: value for key, value in [ + (ATTR_TEMPERATURE, temperature), + (ATTR_ENTITY_ID, entity_id), + (ATTR_OPERATION_MODE, operation_mode) + ] if value is not None + } + _LOGGER.debug("set_temperature start data=%s", kwargs) + hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, kwargs) + + +@bind_hass +def set_operation_mode(hass, operation_mode, entity_id=None): + """Set new target operation mode.""" + data = {ATTR_OPERATION_MODE: operation_mode} + + if entity_id is not None: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SET_OPERATION_MODE, data) diff --git a/tests/components/water_heater/test_demo.py b/tests/components/water_heater/test_demo.py new file mode 100644 index 00000000000..14fe57de99c --- /dev/null +++ b/tests/components/water_heater/test_demo.py @@ -0,0 +1,118 @@ +"""The tests for the demo water_heater component.""" +import unittest + +from homeassistant.util.unit_system import ( + IMPERIAL_SYSTEM +) +from homeassistant.setup import setup_component +from homeassistant.components import water_heater + +from tests.common import get_test_home_assistant +from tests.components.water_heater import common + + +ENTITY_WATER_HEATER = 'water_heater.demo_water_heater' +ENTITY_WATER_HEATER_CELSIUS = 'water_heater.demo_water_heater_celsius' + + +class TestDemowater_heater(unittest.TestCase): + """Test the demo water_heater.""" + + def setUp(self): # pylint: disable=invalid-name + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.units = IMPERIAL_SYSTEM + self.assertTrue(setup_component(self.hass, water_heater.DOMAIN, { + 'water_heater': { + 'platform': 'demo', + }})) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_setup_params(self): + """Test the initial parameters.""" + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual(119, state.attributes.get('temperature')) + self.assertEqual('off', state.attributes.get('away_mode')) + self.assertEqual("eco", state.attributes.get('operation_mode')) + + def test_default_setup_params(self): + """Test the setup with default parameters.""" + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual(110, state.attributes.get('min_temp')) + self.assertEqual(140, state.attributes.get('max_temp')) + + def test_set_only_target_temp_bad_attr(self): + """Test setting the target temperature without required attribute.""" + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual(119, state.attributes.get('temperature')) + common.set_temperature(self.hass, None, ENTITY_WATER_HEATER) + self.hass.block_till_done() + self.assertEqual(119, state.attributes.get('temperature')) + + def test_set_only_target_temp(self): + """Test the setting of the target temperature.""" + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual(119, state.attributes.get('temperature')) + common.set_temperature(self.hass, 110, ENTITY_WATER_HEATER) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual(110, state.attributes.get('temperature')) + + def test_set_operation_bad_attr_and_state(self): + """Test setting operation mode without required attribute. + + Also check the state. + """ + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual("eco", state.attributes.get('operation_mode')) + self.assertEqual("eco", state.state) + common.set_operation_mode(self.hass, None, ENTITY_WATER_HEATER) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual("eco", state.attributes.get('operation_mode')) + self.assertEqual("eco", state.state) + + def test_set_operation(self): + """Test setting of new operation mode.""" + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual("eco", state.attributes.get('operation_mode')) + self.assertEqual("eco", state.state) + common.set_operation_mode(self.hass, "electric", ENTITY_WATER_HEATER) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual("electric", state.attributes.get('operation_mode')) + self.assertEqual("electric", state.state) + + def test_set_away_mode_bad_attr(self): + """Test setting the away mode without required attribute.""" + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual('off', state.attributes.get('away_mode')) + common.set_away_mode(self.hass, None, ENTITY_WATER_HEATER) + self.hass.block_till_done() + self.assertEqual('off', state.attributes.get('away_mode')) + + def test_set_away_mode_on(self): + """Test setting the away mode on/true.""" + common.set_away_mode(self.hass, True, ENTITY_WATER_HEATER) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual('on', state.attributes.get('away_mode')) + + def test_set_away_mode_off(self): + """Test setting the away mode off/false.""" + common.set_away_mode(self.hass, False, ENTITY_WATER_HEATER_CELSIUS) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_WATER_HEATER_CELSIUS) + self.assertEqual('off', state.attributes.get('away_mode')) + + def test_set_only_target_temp_with_convert(self): + """Test the setting of the target temperature.""" + state = self.hass.states.get(ENTITY_WATER_HEATER_CELSIUS) + self.assertEqual(113, state.attributes.get('temperature')) + common.set_temperature(self.hass, 114, ENTITY_WATER_HEATER_CELSIUS) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_WATER_HEATER_CELSIUS) + self.assertEqual(114, state.attributes.get('temperature')) diff --git a/tests/components/weather/test_smhi.py b/tests/components/weather/test_smhi.py new file mode 100644 index 00000000000..11a5028842b --- /dev/null +++ b/tests/components/weather/test_smhi.py @@ -0,0 +1,292 @@ +"""Test for the smhi weather entity.""" +import asyncio +import logging +from datetime import datetime, timezone +from unittest.mock import Mock, patch + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, + ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, + ATTR_FORECAST_TEMP_LOW, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_ATTRIBUTION, + ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, + ATTR_FORECAST_PRECIPITATION, smhi as weather_smhi, + DOMAIN as WEATHER_DOMAIN) +from homeassistant.const import TEMP_CELSIUS +from homeassistant.core import HomeAssistant + +from tests.common import load_fixture, MockConfigEntry + +from homeassistant.components.smhi.const import ATTR_SMHI_CLOUDINESS + +_LOGGER = logging.getLogger(__name__) + +TEST_CONFIG = { + "name": "test", + "longitude": "17.84197", + "latitude": "59.32624" +} + + +async def test_setup_hass(hass: HomeAssistant, aioclient_mock) -> None: + """Test for successfully setting up the smhi platform. + + This test are deeper integrated with the core. Since only + config_flow is used the component are setup with + "async_forward_entry_setup". The actual result are tested + with the entity state rather than "per function" unity tests + """ + from smhi.smhi_lib import APIURL_TEMPLATE + + uri = APIURL_TEMPLATE.format( + TEST_CONFIG['longitude'], TEST_CONFIG['latitude']) + api_response = load_fixture('smhi.json') + aioclient_mock.get(uri, text=api_response) + + entry = MockConfigEntry(domain='smhi', data=TEST_CONFIG) + + await hass.config_entries.async_forward_entry_setup(entry, WEATHER_DOMAIN) + assert aioclient_mock.call_count == 1 + + # Testing the actual entity state for + # deeper testing than normal unity test + state = hass.states.get('weather.smhi_test') + + assert state.state == 'sunny' + assert state.attributes[ATTR_SMHI_CLOUDINESS] == 50 + assert state.attributes[ATTR_WEATHER_ATTRIBUTION].find('SMHI') >= 0 + assert state.attributes[ATTR_WEATHER_HUMIDITY] == 55 + assert state.attributes[ATTR_WEATHER_PRESSURE] == 1024 + assert state.attributes[ATTR_WEATHER_TEMPERATURE] == 17 + assert state.attributes[ATTR_WEATHER_VISIBILITY] == 50 + assert state.attributes[ATTR_WEATHER_WIND_SPEED] == 7 + assert state.attributes[ATTR_WEATHER_WIND_BEARING] == 134 + _LOGGER.error(state.attributes) + assert len(state.attributes['forecast']) == 1 + + forecast = state.attributes['forecast'][0] + assert forecast[ATTR_FORECAST_TIME] == datetime(2018, 9, 2, 12, 0, + tzinfo=timezone.utc) + assert forecast[ATTR_FORECAST_TEMP] == 20 + assert forecast[ATTR_FORECAST_TEMP_LOW] == 6 + assert forecast[ATTR_FORECAST_PRECIPITATION] == 0 + assert forecast[ATTR_FORECAST_CONDITION] == 'partlycloudy' + + +async def test_setup_plattform(hass): + """Test that setup plattform does nothing.""" + assert await weather_smhi.async_setup_platform(hass, None, None) is None + + +def test_properties_no_data(hass: HomeAssistant) -> None: + """Test properties when no API data available.""" + weather = weather_smhi.SmhiWeather('name', '10', '10') + weather.hass = hass + + assert weather.name == 'name' + assert weather.should_poll is True + assert weather.temperature is None + assert weather.humidity is None + assert weather.wind_speed is None + assert weather.wind_bearing is None + assert weather.visibility is None + assert weather.pressure is None + assert weather.cloudiness is None + assert weather.condition is None + assert weather.forecast is None + assert weather.temperature_unit == TEMP_CELSIUS + + +# pylint: disable=W0212 +def test_properties_unknown_symbol() -> None: + """Test behaviour when unknown symbol from API.""" + hass = Mock() + data = Mock() + data.temperature = 5 + data.mean_precipitation = 1 + data.humidity = 5 + data.wind_speed = 10 + data.wind_direction = 180 + data.horizontal_visibility = 6 + data.pressure = 1008 + data.cloudiness = 52 + data.symbol = 100 # Faulty symbol + data.valid_time = datetime(2018, 1, 1, 0, 1, 2) + + data2 = Mock() + data2.temperature = 5 + data2.mean_precipitation = 1 + data2.humidity = 5 + data2.wind_speed = 10 + data2.wind_direction = 180 + data2.horizontal_visibility = 6 + data2.pressure = 1008 + data2.cloudiness = 52 + data2.symbol = 100 # Faulty symbol + data2.valid_time = datetime(2018, 1, 1, 12, 1, 2) + + data3 = Mock() + data3.temperature = 5 + data3.mean_precipitation = 1 + data3.humidity = 5 + data3.wind_speed = 10 + data3.wind_direction = 180 + data3.horizontal_visibility = 6 + data3.pressure = 1008 + data3.cloudiness = 52 + data3.symbol = 100 # Faulty symbol + data3.valid_time = datetime(2018, 1, 2, 12, 1, 2) + + testdata = [ + data, + data2, + data3 + ] + + weather = weather_smhi.SmhiWeather('name', '10', '10') + weather.hass = hass + weather._forecasts = testdata + assert weather.condition is None + forecast = weather.forecast[0] + assert forecast[ATTR_FORECAST_CONDITION] is None + + +# pylint: disable=W0212 +async def test_refresh_weather_forecast_exceeds_retries(hass) -> None: + """Test the refresh weather forecast function.""" + from smhi.smhi_lib import SmhiForecastException + + with \ + patch.object(hass.helpers.event, 'async_call_later') as call_later, \ + patch.object(weather_smhi.SmhiWeather, 'get_weather_forecast', + side_effect=SmhiForecastException()): + + weather = weather_smhi.SmhiWeather( + 'name', '17.0022', '62.0022') + weather.hass = hass + weather._fail_count = 2 + + await weather.async_update() + assert weather._forecasts is None + assert not call_later.mock_calls + + +async def test_refresh_weather_forecast_timeout(hass) -> None: + """Test timeout exception.""" + weather = weather_smhi.SmhiWeather( + 'name', '17.0022', '62.0022') + weather.hass = hass + + with \ + patch.object(hass.helpers.event, 'async_call_later') as call_later, \ + patch.object(weather_smhi.SmhiWeather, 'retry_update'), \ + patch.object(weather_smhi.SmhiWeather, 'get_weather_forecast', + side_effect=asyncio.TimeoutError): + + await weather.async_update() + assert len(call_later.mock_calls) == 1 + # Assert we are going to wait RETRY_TIMEOUT seconds + assert call_later.mock_calls[0][1][0] == weather_smhi.RETRY_TIMEOUT + + +async def test_refresh_weather_forecast_exception() -> None: + """Test any exception.""" + from smhi.smhi_lib import SmhiForecastException + + hass = Mock() + weather = weather_smhi.SmhiWeather( + 'name', '17.0022', '62.0022') + weather.hass = hass + + with \ + patch.object(hass.helpers.event, 'async_call_later') as call_later, \ + patch.object(weather_smhi, 'async_timeout'), \ + patch.object(weather_smhi.SmhiWeather, 'retry_update'), \ + patch.object(weather_smhi.SmhiWeather, 'get_weather_forecast', + side_effect=SmhiForecastException()): + + hass.async_add_job = Mock() + call_later = hass.helpers.event.async_call_later + + await weather.async_update() + assert len(call_later.mock_calls) == 1 + # Assert we are going to wait RETRY_TIMEOUT seconds + assert call_later.mock_calls[0][1][0] == weather_smhi.RETRY_TIMEOUT + + +async def test_retry_update(): + """Test retry function of refresh forecast.""" + hass = Mock() + weather = weather_smhi.SmhiWeather( + 'name', '17.0022', '62.0022') + weather.hass = hass + + with patch.object(weather_smhi.SmhiWeather, + 'async_update') as update: + await weather.retry_update() + assert len(update.mock_calls) == 1 + + +def test_condition_class(): + """Test condition class.""" + def get_condition(index: int) -> str: + """Return condition given index.""" + return [k for k, v in weather_smhi.CONDITION_CLASSES.items() + if index in v][0] + + # SMHI definitions as follows, see + # http://opendata.smhi.se/apidocs/metfcst/parameters.html + + # 1. Clear sky + assert get_condition(1) == 'sunny' + # 2. Nearly clear sky + assert get_condition(2) == 'sunny' + # 3. Variable cloudiness + assert get_condition(3) == 'partlycloudy' + # 4. Halfclear sky + assert get_condition(4) == 'partlycloudy' + # 5. Cloudy sky + assert get_condition(5) == 'cloudy' + # 6. Overcast + assert get_condition(6) == 'cloudy' + # 7. Fog + assert get_condition(7) == 'fog' + # 8. Light rain showers + assert get_condition(8) == 'rainy' + # 9. Moderate rain showers + assert get_condition(9) == 'rainy' + # 18. Light rain + assert get_condition(18) == 'rainy' + # 19. Moderate rain + assert get_condition(19) == 'rainy' + # 10. Heavy rain showers + assert get_condition(10) == 'pouring' + # 20. Heavy rain + assert get_condition(20) == 'pouring' + # 21. Thunder + assert get_condition(21) == 'lightning' + # 11. Thunderstorm + assert get_condition(11) == 'lightning-rainy' + # 15. Light snow showers + assert get_condition(15) == 'snowy' + # 16. Moderate snow showers + assert get_condition(16) == 'snowy' + # 17. Heavy snow showers + assert get_condition(17) == 'snowy' + # 25. Light snowfall + assert get_condition(25) == 'snowy' + # 26. Moderate snowfall + assert get_condition(26) == 'snowy' + # 27. Heavy snowfall + assert get_condition(27) == 'snowy' + # 12. Light sleet showers + assert get_condition(12) == 'snowy-rainy' + # 13. Moderate sleet showers + assert get_condition(13) == 'snowy-rainy' + # 14. Heavy sleet showers + assert get_condition(14) == 'snowy-rainy' + # 22. Light sleet + assert get_condition(22) == 'snowy-rainy' + # 23. Moderate sleet + assert get_condition(23) == 'snowy-rainy' + # 24. Heavy sleet + assert get_condition(24) == 'snowy-rainy' diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index a2290d8aabf..b06dfb683b7 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -2,6 +2,7 @@ import asyncio from collections import OrderedDict from datetime import datetime +from pytz import utc import unittest from unittest.mock import patch, MagicMock @@ -22,13 +23,6 @@ from tests.common import ( from tests.mock.zwave import MockNetwork, MockNode, MockValue, MockEntityValues -@asyncio.coroutine -def test_missing_openzwave(hass): - """Test that missing openzwave lib stops setup.""" - result = yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - assert not result - - @asyncio.coroutine def test_valid_device_config(hass, mock_openzwave): """Test valid device config.""" @@ -41,6 +35,7 @@ def test_valid_device_config(hass, mock_openzwave): 'zwave': { 'device_config': device_config }}) + yield from hass.async_block_till_done() assert result @@ -57,6 +52,7 @@ def test_invalid_device_config(hass, mock_openzwave): 'zwave': { 'device_config': device_config }}) + yield from hass.async_block_till_done() assert not result @@ -81,6 +77,7 @@ def test_network_options(hass, mock_openzwave): 'usb_path': 'mock_usb_path', 'config_path': 'mock_config_path', }}) + yield from hass.async_block_till_done() assert result @@ -92,14 +89,16 @@ def test_network_options(hass, mock_openzwave): @asyncio.coroutine def test_auto_heal_midnight(hass, mock_openzwave): """Test network auto-heal at midnight.""" - assert (yield from async_setup_component(hass, 'zwave', { + yield from async_setup_component(hass, 'zwave', { 'zwave': { 'autoheal': True, - }})) + }}) + yield from hass.async_block_till_done() + network = hass.data[zwave.DATA_NETWORK] assert not network.heal.called - time = datetime(2017, 5, 6, 0, 0, 0) + time = utc.localize(datetime(2017, 5, 6, 0, 0, 0)) async_fire_time_changed(hass, time) yield from hass.async_block_till_done() assert network.heal.called @@ -109,14 +108,16 @@ def test_auto_heal_midnight(hass, mock_openzwave): @asyncio.coroutine def test_auto_heal_disabled(hass, mock_openzwave): """Test network auto-heal disabled.""" - assert (yield from async_setup_component(hass, 'zwave', { + yield from async_setup_component(hass, 'zwave', { 'zwave': { 'autoheal': False, - }})) + }}) + yield from hass.async_block_till_done() + network = hass.data[zwave.DATA_NETWORK] assert not network.heal.called - time = datetime(2017, 5, 6, 0, 0, 0) + time = utc.localize(datetime(2017, 5, 6, 0, 0, 0)) async_fire_time_changed(hass, time) yield from hass.async_block_till_done() assert not network.heal.called @@ -215,6 +216,7 @@ def test_node_discovery(hass, mock_openzwave): with patch('pydispatch.dispatcher.connect', new=mock_connect): yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + yield from hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -235,6 +237,7 @@ async def test_unparsed_node_discovery(hass, mock_openzwave): with patch('pydispatch.dispatcher.connect', new=mock_connect): await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -282,6 +285,7 @@ def test_node_ignored(hass, mock_openzwave): 'zwave.mock_node': { 'ignored': True, }}}}) + yield from hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -303,6 +307,7 @@ def test_value_discovery(hass, mock_openzwave): with patch('pydispatch.dispatcher.connect', new=mock_connect): yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + yield from hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -328,6 +333,7 @@ def test_value_discovery_existing_entity(hass, mock_openzwave): with patch('pydispatch.dispatcher.connect', new=mock_connect): yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + yield from hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -373,6 +379,7 @@ def test_power_schemes(hass, mock_openzwave): with patch('pydispatch.dispatcher.connect', new=mock_connect): yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + yield from hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -415,6 +422,7 @@ def test_network_ready(hass, mock_openzwave): with patch('pydispatch.dispatcher.connect', new=mock_connect): yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + yield from hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -442,6 +450,7 @@ def test_network_complete(hass, mock_openzwave): with patch('pydispatch.dispatcher.connect', new=mock_connect): yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + yield from hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -469,6 +478,7 @@ def test_network_complete_some_dead(hass, mock_openzwave): with patch('pydispatch.dispatcher.connect', new=mock_connect): yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + yield from hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -693,21 +703,24 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): const.COMMAND_CLASS_SWITCH_BINARY], }}} - values = zwave.ZWaveDeviceEntityValues( - hass=self.hass, - schema=self.mock_schema, - primary_value=self.primary, - zwave_config=self.zwave_config, - device_config=self.device_config, - registry=self.registry - ) - values._check_entity_ready() - self.hass.block_till_done() + with patch.object(zwave, 'async_dispatcher_send') as \ + mock_dispatch_send: - assert discovery.async_load_platform.called - assert len(discovery.async_load_platform.mock_calls) == 1 - args = discovery.async_load_platform.mock_calls[0][1] - assert args[1] == 'binary_sensor' + values = zwave.ZWaveDeviceEntityValues( + hass=self.hass, + schema=self.mock_schema, + primary_value=self.primary, + zwave_config=self.zwave_config, + device_config=self.device_config, + registry=self.registry + ) + values._check_entity_ready() + self.hass.block_till_done() + + assert mock_dispatch_send.called + assert len(mock_dispatch_send.mock_calls) == 1 + args = mock_dispatch_send.mock_calls[0][1] + assert args[1] == 'zwave_new_binary_sensor' @patch.object(zwave, 'get_platform') @patch.object(zwave, 'discovery') diff --git a/tests/fixtures/smhi.json b/tests/fixtures/smhi.json new file mode 100644 index 00000000000..f66cc546018 --- /dev/null +++ b/tests/fixtures/smhi.json @@ -0,0 +1,1599 @@ +{ + "approvedTime": "2018-09-01T14:06:18Z", + "referenceTime": "2018-09-01T14:00:00Z", + "geometry": { + "type": "Point", + "coordinates": [ + [ + 16.024394, + 63.341937 + ] + ] + }, + "timeSeries": [ + { + "validTime": "2018-09-01T15:00:00Z", + "parameters": [ + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + -9 + ] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 1 + ] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 2 + ] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 1 + ] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 4 + ] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [ + 1024.6 + ] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [ + 17 + ] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [ + 50 + ] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [ + 134 + ] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 1.9 + ] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [ + 55 + ] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 33 + ] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 4.7 + ] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 1 + ] + } + ] + }, + { + "validTime": "2018-09-02T00:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [ + 1026 + ] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [ + 6 + ] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [ + 12 + ] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [ + 214 + ] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 0.7 + ] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [ + 87 + ] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 0 + ] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 1.5 + ] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + -9 + ] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 0 + ] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 1 + ] + } + ] + }, + { + "validTime": "2018-09-02T11:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [ + 1026.6 + ] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [ + 19.8 + ] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [ + 50 + ] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [ + 201 + ] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 1.8 + ] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [ + 43 + ] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 0 + ] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 6 + ] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 6 + ] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 5.2 + ] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + -9 + ] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 0 + ] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 3 + ] + } + ] + }, + { + "validTime": "2018-09-02T12:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [ + 1026.5 + ] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [ + 20.6 + ] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [ + 50 + ] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [ + 203 + ] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 1.7 + ] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [ + 43 + ] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 0 + ] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 9 + ] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 6 + ] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 5.1 + ] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + -9 + ] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 0 + ] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 3 + ] + } + ] + }, + { + "validTime": "2018-09-02T23:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [ + 1026 + ] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [ + 9.3 + ] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [ + 19.4 + ] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [ + 95 + ] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 0.5 + ] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [ + 75 + ] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 1 + ] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 1.1 + ] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + -9 + ] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 0 + ] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 1 + ] + } + ] + }, + { + "validTime": "2018-09-03T00:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [ + 1025.9 + ] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [ + 8.5 + ] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [ + 50 + ] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [ + 104 + ] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 0.5 + ] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [ + 73 + ] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 1 + ] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 1.1 + ] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + -9 + ] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 0 + ] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 1 + ] + } + ] + }, + { + "validTime": "2018-09-03T01:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [ + 1025.6 + ] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [ + 8 + ] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [ + 50 + ] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [ + 116 + ] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 0.3 + ] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [ + 74 + ] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 1 + ] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 1 + ] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + -9 + ] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 0 + ] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 1 + ] + } + ] + }, + { + "validTime": "2018-09-04T12:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [ + 1020.5 + ] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [ + 19.2 + ] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [ + 50 + ] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [ + 353 + ] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 1.4 + ] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [ + 60 + ] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 0 + ] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 7 + ] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 3 + ] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 5 + ] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 4 + ] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 4.7 + ] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + -9 + ] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 0 + ] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 4 + ] + } + ] + }, + { + "validTime": "2018-09-04T18:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [ + 1021.5 + ] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [ + 14.3 + ] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [ + 50 + ] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [ + 333 + ] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 2.3 + ] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [ + 81 + ] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 0 + ] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 4 + ] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 1 + ] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 4 + ] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 4.5 + ] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0.2 + ] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 0 + ] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 4 + ] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 3 + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index ab575c61789..cfd84dbc3b3 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -356,9 +356,15 @@ def test_string(): """Test string validation.""" schema = vol.Schema(cv.string) - with pytest.raises(vol.MultipleInvalid): + with pytest.raises(vol.Invalid): schema(None) + with pytest.raises(vol.Invalid): + schema([]) + + with pytest.raises(vol.Invalid): + schema({}) + for value in (True, 1, 'hello'): schema(value) diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 1a0c248383b..c853d0b3447 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -415,3 +415,19 @@ async def test_unload_entry_fails_if_never_loaded(hass): with pytest.raises(ValueError): await component.async_unload_entry(entry) + + +async def test_update_entity(hass): + """Test that we can update an entity with the helper.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + entity = MockEntity() + entity.async_update_ha_state = Mock(return_value=mock_coro()) + await component.async_add_entities([entity]) + + # Called as part of async_add_entities + assert len(entity.async_update_ha_state.mock_calls) == 1 + + await hass.helpers.entity_component.async_update_entity(entity.entity_id) + + assert len(entity.async_update_ha_state.mock_calls) == 2 + assert entity.async_update_ha_state.mock_calls[-1][1][0] is True diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 5b57ca75d51..cb586698302 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -85,38 +85,6 @@ class TestEventHelpers(unittest.TestCase): self.hass.block_till_done() self.assertEqual(2, len(runs)) - def test_track_time_change(self): - """Test tracking time change.""" - wildcard_runs = [] - specific_runs = [] - - unsub = track_time_change(self.hass, lambda x: wildcard_runs.append(1)) - unsub_utc = track_utc_time_change( - self.hass, lambda x: specific_runs.append(1), second=[0, 30]) - - self._send_time_changed(datetime(2014, 5, 24, 12, 0, 0)) - self.hass.block_till_done() - self.assertEqual(1, len(specific_runs)) - self.assertEqual(1, len(wildcard_runs)) - - self._send_time_changed(datetime(2014, 5, 24, 12, 0, 15)) - self.hass.block_till_done() - self.assertEqual(1, len(specific_runs)) - self.assertEqual(2, len(wildcard_runs)) - - self._send_time_changed(datetime(2014, 5, 24, 12, 0, 30)) - self.hass.block_till_done() - self.assertEqual(2, len(specific_runs)) - self.assertEqual(3, len(wildcard_runs)) - - unsub() - unsub_utc() - - self._send_time_changed(datetime(2014, 5, 24, 12, 0, 30)) - self.hass.block_till_done() - self.assertEqual(2, len(specific_runs)) - self.assertEqual(3, len(wildcard_runs)) - def test_track_state_change(self): """Test track_state_change.""" # 2 lists to track how often our callbacks get called @@ -526,12 +494,64 @@ class TestEventHelpers(unittest.TestCase): """Send a time changed event.""" self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now}) + +class TestTrackTimeChange(unittest.TestCase): + """Test track time change methods.""" + + def setUp(self): + """Set up the tests.""" + self.orig_default_time_zone = dt_util.DEFAULT_TIME_ZONE + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + dt_util.set_default_time_zone(self.orig_default_time_zone) + self.hass.stop() + + def _send_time_changed(self, now): + """Send a time changed event.""" + self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now}) + + def test_track_time_change(self): + """Test tracking time change.""" + wildcard_runs = [] + specific_runs = [] + + unsub = track_time_change(self.hass, + lambda x: wildcard_runs.append(1)) + unsub_utc = track_utc_time_change( + self.hass, lambda x: specific_runs.append(1), second=[0, 30]) + + self._send_time_changed(datetime(2014, 5, 24, 12, 0, 0)) + self.hass.block_till_done() + self.assertEqual(1, len(specific_runs)) + self.assertEqual(1, len(wildcard_runs)) + + self._send_time_changed(datetime(2014, 5, 24, 12, 0, 15)) + self.hass.block_till_done() + self.assertEqual(1, len(specific_runs)) + self.assertEqual(2, len(wildcard_runs)) + + self._send_time_changed(datetime(2014, 5, 24, 12, 0, 30)) + self.hass.block_till_done() + self.assertEqual(2, len(specific_runs)) + self.assertEqual(3, len(wildcard_runs)) + + unsub() + unsub_utc() + + self._send_time_changed(datetime(2014, 5, 24, 12, 0, 30)) + self.hass.block_till_done() + self.assertEqual(2, len(specific_runs)) + self.assertEqual(3, len(wildcard_runs)) + def test_periodic_task_minute(self): """Test periodic tasks per minute.""" specific_runs = [] unsub = track_utc_time_change( - self.hass, lambda x: specific_runs.append(1), minute='/5') + self.hass, lambda x: specific_runs.append(1), minute='/5', + second=0) self._send_time_changed(datetime(2014, 5, 24, 12, 0, 0)) self.hass.block_till_done() @@ -556,7 +576,8 @@ class TestEventHelpers(unittest.TestCase): specific_runs = [] unsub = track_utc_time_change( - self.hass, lambda x: specific_runs.append(1), hour='/2') + self.hass, lambda x: specific_runs.append(1), hour='/2', + minute=0, second=0) self._send_time_changed(datetime(2014, 5, 24, 22, 0, 0)) self.hass.block_till_done() @@ -566,7 +587,7 @@ class TestEventHelpers(unittest.TestCase): self.hass.block_till_done() self.assertEqual(1, len(specific_runs)) - self._send_time_changed(datetime(2014, 5, 24, 0, 0, 0)) + self._send_time_changed(datetime(2014, 5, 25, 0, 0, 0)) self.hass.block_till_done() self.assertEqual(2, len(specific_runs)) @@ -584,68 +605,138 @@ class TestEventHelpers(unittest.TestCase): self.hass.block_till_done() self.assertEqual(3, len(specific_runs)) - def test_periodic_task_day(self): - """Test periodic tasks per day.""" - specific_runs = [] - - unsub = track_utc_time_change( - self.hass, lambda x: specific_runs.append(1), day='/2') - - self._send_time_changed(datetime(2014, 5, 2, 0, 0, 0)) - self.hass.block_till_done() - self.assertEqual(1, len(specific_runs)) - - self._send_time_changed(datetime(2014, 5, 3, 12, 0, 0)) - self.hass.block_till_done() - self.assertEqual(1, len(specific_runs)) - - self._send_time_changed(datetime(2014, 5, 4, 0, 0, 0)) - self.hass.block_till_done() - self.assertEqual(2, len(specific_runs)) - - unsub() - - self._send_time_changed(datetime(2014, 5, 4, 0, 0, 0)) - self.hass.block_till_done() - self.assertEqual(2, len(specific_runs)) - - def test_periodic_task_year(self): - """Test periodic tasks per year.""" - specific_runs = [] - - unsub = track_utc_time_change( - self.hass, lambda x: specific_runs.append(1), year='/2') - - self._send_time_changed(datetime(2014, 5, 2, 0, 0, 0)) - self.hass.block_till_done() - self.assertEqual(1, len(specific_runs)) - - self._send_time_changed(datetime(2015, 5, 2, 0, 0, 0)) - self.hass.block_till_done() - self.assertEqual(1, len(specific_runs)) - - self._send_time_changed(datetime(2016, 5, 2, 0, 0, 0)) - self.hass.block_till_done() - self.assertEqual(2, len(specific_runs)) - - unsub() - - self._send_time_changed(datetime(2016, 5, 2, 0, 0, 0)) - self.hass.block_till_done() - self.assertEqual(2, len(specific_runs)) - def test_periodic_task_wrong_input(self): """Test periodic tasks with wrong input.""" specific_runs = [] with pytest.raises(ValueError): track_utc_time_change( - self.hass, lambda x: specific_runs.append(1), year='/two') + self.hass, lambda x: specific_runs.append(1), hour='/two') self._send_time_changed(datetime(2014, 5, 2, 0, 0, 0)) self.hass.block_till_done() self.assertEqual(0, len(specific_runs)) + def test_periodic_task_clock_rollback(self): + """Test periodic tasks with the time rolling backwards.""" + specific_runs = [] + + unsub = track_utc_time_change( + self.hass, lambda x: specific_runs.append(1), hour='/2', minute=0, + second=0) + + self._send_time_changed(datetime(2014, 5, 24, 22, 0, 0)) + self.hass.block_till_done() + self.assertEqual(1, len(specific_runs)) + + self._send_time_changed(datetime(2014, 5, 24, 23, 0, 0)) + self.hass.block_till_done() + self.assertEqual(1, len(specific_runs)) + + self._send_time_changed(datetime(2014, 5, 24, 22, 0, 0)) + self.hass.block_till_done() + self.assertEqual(2, len(specific_runs)) + + self._send_time_changed(datetime(2014, 5, 24, 0, 0, 0)) + self.hass.block_till_done() + self.assertEqual(3, len(specific_runs)) + + self._send_time_changed(datetime(2014, 5, 25, 2, 0, 0)) + self.hass.block_till_done() + self.assertEqual(4, len(specific_runs)) + + unsub() + + self._send_time_changed(datetime(2014, 5, 25, 2, 0, 0)) + self.hass.block_till_done() + self.assertEqual(4, len(specific_runs)) + + def test_periodic_task_duplicate_time(self): + """Test periodic tasks not triggering on duplicate time.""" + specific_runs = [] + + unsub = track_utc_time_change( + self.hass, lambda x: specific_runs.append(1), hour='/2', minute=0, + second=0) + + self._send_time_changed(datetime(2014, 5, 24, 22, 0, 0)) + self.hass.block_till_done() + self.assertEqual(1, len(specific_runs)) + + self._send_time_changed(datetime(2014, 5, 24, 22, 0, 0)) + self.hass.block_till_done() + self.assertEqual(1, len(specific_runs)) + + self._send_time_changed(datetime(2014, 5, 25, 0, 0, 0)) + self.hass.block_till_done() + self.assertEqual(2, len(specific_runs)) + + unsub() + + def test_periodic_task_entering_dst(self): + """Test periodic task behavior when entering dst.""" + tz = dt_util.get_time_zone('Europe/Vienna') + dt_util.set_default_time_zone(tz) + specific_runs = [] + + unsub = track_time_change( + self.hass, lambda x: specific_runs.append(1), hour=2, minute=30, + second=0) + + self._send_time_changed( + tz.localize(datetime(2018, 3, 25, 1, 50, 0))) + self.hass.block_till_done() + self.assertEqual(0, len(specific_runs)) + + self._send_time_changed( + tz.localize(datetime(2018, 3, 25, 3, 50, 0))) + self.hass.block_till_done() + self.assertEqual(0, len(specific_runs)) + + self._send_time_changed( + tz.localize(datetime(2018, 3, 26, 1, 50, 0))) + self.hass.block_till_done() + self.assertEqual(0, len(specific_runs)) + + self._send_time_changed( + tz.localize(datetime(2018, 3, 26, 2, 50, 0))) + self.hass.block_till_done() + self.assertEqual(1, len(specific_runs)) + + unsub() + + def test_periodic_task_leaving_dst(self): + """Test periodic task behavior when leaving dst.""" + tz = dt_util.get_time_zone('Europe/Vienna') + dt_util.set_default_time_zone(tz) + specific_runs = [] + + unsub = track_time_change( + self.hass, lambda x: specific_runs.append(1), hour=2, minute=30, + second=0) + + self._send_time_changed( + tz.localize(datetime(2018, 10, 28, 2, 5, 0), is_dst=False)) + self.hass.block_till_done() + self.assertEqual(0, len(specific_runs)) + + self._send_time_changed( + tz.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=False)) + self.hass.block_till_done() + self.assertEqual(1, len(specific_runs)) + + self._send_time_changed( + tz.localize(datetime(2018, 10, 28, 2, 5, 0), is_dst=True)) + self.hass.block_till_done() + self.assertEqual(1, len(specific_runs)) + + self._send_time_changed( + tz.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=True)) + self.hass.block_till_done() + self.assertEqual(2, len(specific_runs)) + + unsub() + def test_call_later(self): """Test calling an action later.""" def action(): pass diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index dc8106e0ed3..2ead38ba345 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -682,6 +682,32 @@ class TestHelpersTemplate(unittest.TestCase): 'None', tpl.render()) + def test_distance_function_with_2_entity_ids(self): + """Test distance function with 2 entity ids.""" + self.hass.states.set('test.object', 'happy', { + 'latitude': 32.87336, + 'longitude': -117.22943, + }) + self.hass.states.set('test.object_2', 'happy', { + 'latitude': self.hass.config.latitude, + 'longitude': self.hass.config.longitude, + }) + tpl = template.Template( + '{{ distance("test.object", "test.object_2") | round }}', + self.hass) + self.assertEqual('187', tpl.render()) + + def test_distance_function_with_1_entity_1_coord(self): + """Test distance function with 1 entity_id and 1 coord.""" + self.hass.states.set('test.object', 'happy', { + 'latitude': self.hass.config.latitude, + 'longitude': self.hass.config.longitude, + }) + tpl = template.Template( + '{{ distance("test.object", "32.87336", "-117.22943") | round }}', + self.hass) + self.assertEqual('187', tpl.render()) + def test_closest_function_home_vs_domain(self): """Test closest function home vs domain.""" self.hass.states.set('test_domain.object', 'happy', { diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 340118502b1..59777e2e6bb 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -11,7 +11,8 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt from tests.common import ( - MockModule, mock_coro, MockConfigEntry, async_fire_time_changed) + MockModule, mock_coro, MockConfigEntry, async_fire_time_changed, + MockPlatform, MockEntity) @pytest.fixture @@ -40,35 +41,87 @@ def test_call_setup_entry(hass): assert len(mock_setup_entry.mock_calls) == 1 -@asyncio.coroutine -def test_remove_entry(hass, manager): +async def test_remove_entry(hass, manager): """Test that we can remove an entry.""" - mock_unload_entry = MagicMock(return_value=mock_coro(True)) + async def mock_setup_entry(hass, entry): + """Mock setting up entry.""" + hass.loop.create_task(hass.config_entries.async_forward_entry_setup( + entry, 'light')) + return True + async def mock_unload_entry(hass, entry): + """Mock unloading an entry.""" + result = await hass.config_entries.async_forward_entry_unload( + entry, 'light') + assert result + return result + + entity = MockEntity( + unique_id='1234', + name='Test Entity', + ) + + async def mock_setup_entry_platform(hass, entry, async_add_entities): + """Mock setting up platform.""" + async_add_entities([entity]) + + loader.set_component(hass, 'test', MockModule( + 'test', + async_setup_entry=mock_setup_entry, + async_unload_entry=mock_unload_entry + )) loader.set_component( - hass, 'test', - MockModule('comp', async_unload_entry=mock_unload_entry)) + hass, 'light.test', + MockPlatform(async_setup_entry=mock_setup_entry_platform)) MockConfigEntry(domain='test', entry_id='test1').add_to_manager(manager) - MockConfigEntry( + entry = MockConfigEntry( domain='test', entry_id='test2', - state=config_entries.ENTRY_STATE_LOADED - ).add_to_manager(manager) + ) + entry.add_to_manager(manager) MockConfigEntry(domain='test', entry_id='test3').add_to_manager(manager) + # Check all config entries exist assert [item.entry_id for item in manager.async_entries()] == \ ['test1', 'test2', 'test3'] - result = yield from manager.async_remove('test2') + # Setup entry + await entry.async_setup(hass) + await hass.async_block_till_done() + # Check entity state got added + assert hass.states.get('light.test_entity') is not None + # Group all_lights, light.test_entity + assert len(hass.states.async_all()) == 2 + + # Check entity got added to entity registry + ent_reg = await hass.helpers.entity_registry.async_get_registry() + assert len(ent_reg.entities) == 1 + entity_entry = list(ent_reg.entities.values())[0] + assert entity_entry.config_entry_id == entry.entry_id + + # Remove entry + result = await manager.async_remove('test2') + await hass.async_block_till_done() + + # Check that unload went well and so no need to restart assert result == { 'require_restart': False } + + # Check that config entry was removed. assert [item.entry_id for item in manager.async_entries()] == \ ['test1', 'test3'] - assert len(mock_unload_entry.mock_calls) == 1 + # Check that entity state has been removed + assert hass.states.get('light.test_entity') is None + # Just Group all_lights + assert len(hass.states.async_all()) == 1 + + # Check that entity registry entry no longer references config_entry_id + entity_entry = list(ent_reg.entities.values())[0] + assert entity_entry.config_entry_id is None @asyncio.coroutine diff --git a/tests/test_core.py b/tests/test_core.py index d88257abfb4..7ab624447c5 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -19,9 +19,9 @@ import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import (METRIC_SYSTEM) from homeassistant.const import ( __version__, EVENT_STATE_CHANGED, ATTR_FRIENDLY_NAME, CONF_UNIT_SYSTEM, - ATTR_NOW, EVENT_TIME_CHANGED, EVENT_HOMEASSISTANT_STOP, - EVENT_HOMEASSISTANT_CLOSE, EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, - EVENT_SERVICE_EXECUTED) + ATTR_NOW, EVENT_TIME_CHANGED, EVENT_TIMER_OUT_OF_SYNC, ATTR_SECONDS, + EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_CLOSE, + EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, EVENT_SERVICE_EXECUTED) from tests.common import get_test_home_assistant, async_mock_service @@ -916,12 +916,13 @@ def test_timer_out_of_sync(mock_monotonic, loop): delay, callback, target = hass.loop.call_later.mock_calls[0][1] - with patch.object(ha, '_LOGGER', MagicMock()) as mock_logger, \ - patch('homeassistant.core.dt_util.utcnow', - return_value=datetime(2018, 12, 31, 3, 4, 8, 200000)): + with patch('homeassistant.core.dt_util.utcnow', + return_value=datetime(2018, 12, 31, 3, 4, 8, 200000)): callback(target) - assert len(mock_logger.error.mock_calls) == 1 + event_type, event_data = hass.bus.async_fire.mock_calls[1][1] + assert event_type == EVENT_TIMER_OUT_OF_SYNC + assert abs(event_data[ATTR_SECONDS] - 2.433333) < 0.001 assert len(funcs) == 2 fire_time_event, stop_timer = funcs diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index d670917c055..35a83de6bfb 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -164,3 +164,128 @@ class TestDateUtil(unittest.TestCase): diff = dt_util.now() - timedelta(minutes=365*60*24) self.assertEqual(dt_util.get_age(diff), "1 year") + + def test_parse_time_expression(self): + """Test parse_time_expression.""" + self.assertEqual( + [x for x in range(60)], + dt_util.parse_time_expression('*', 0, 59) + ) + self.assertEqual( + [x for x in range(60)], + dt_util.parse_time_expression(None, 0, 59) + ) + + self.assertEqual( + [x for x in range(0, 60, 5)], + dt_util.parse_time_expression('/5', 0, 59) + ) + + self.assertEqual( + [1, 2, 3], + dt_util.parse_time_expression([2, 1, 3], 0, 59) + ) + + self.assertEqual( + [x for x in range(24)], + dt_util.parse_time_expression('*', 0, 23) + ) + + self.assertEqual( + [42], + dt_util.parse_time_expression(42, 0, 59) + ) + + self.assertRaises(ValueError, dt_util.parse_time_expression, 61, 0, 60) + + def test_find_next_time_expression_time_basic(self): + """Test basic stuff for find_next_time_expression_time.""" + def find(dt, hour, minute, second): + """Call test_find_next_time_expression_time.""" + seconds = dt_util.parse_time_expression(second, 0, 59) + minutes = dt_util.parse_time_expression(minute, 0, 59) + hours = dt_util.parse_time_expression(hour, 0, 23) + + return dt_util.find_next_time_expression_time( + dt, seconds, minutes, hours) + + self.assertEqual( + datetime(2018, 10, 7, 10, 30, 0), + find(datetime(2018, 10, 7, 10, 20, 0), '*', '/30', 0) + ) + + self.assertEqual( + datetime(2018, 10, 7, 10, 30, 0), + find(datetime(2018, 10, 7, 10, 30, 0), '*', '/30', 0) + ) + + self.assertEqual( + datetime(2018, 10, 7, 12, 30, 30), + find(datetime(2018, 10, 7, 10, 30, 0), '/3', '/30', [30, 45]) + ) + + self.assertEqual( + datetime(2018, 10, 8, 5, 0, 0), + find(datetime(2018, 10, 7, 10, 30, 0), 5, 0, 0) + ) + + def test_find_next_time_expression_time_dst(self): + """Test daylight saving time for find_next_time_expression_time.""" + tz = dt_util.get_time_zone('Europe/Vienna') + dt_util.set_default_time_zone(tz) + + def find(dt, hour, minute, second): + """Call test_find_next_time_expression_time.""" + seconds = dt_util.parse_time_expression(second, 0, 59) + minutes = dt_util.parse_time_expression(minute, 0, 59) + hours = dt_util.parse_time_expression(hour, 0, 23) + + return dt_util.find_next_time_expression_time( + dt, seconds, minutes, hours) + + # Entering DST, clocks are rolled forward + self.assertEqual( + tz.localize(datetime(2018, 3, 26, 2, 30, 0)), + find(tz.localize(datetime(2018, 3, 25, 1, 50, 0)), 2, 30, 0) + ) + + self.assertEqual( + tz.localize(datetime(2018, 3, 26, 2, 30, 0)), + find(tz.localize(datetime(2018, 3, 25, 3, 50, 0)), 2, 30, 0) + ) + + self.assertEqual( + tz.localize(datetime(2018, 3, 26, 2, 30, 0)), + find(tz.localize(datetime(2018, 3, 26, 1, 50, 0)), 2, 30, 0) + ) + + # Leaving DST, clocks are rolled back + self.assertEqual( + tz.localize(datetime(2018, 10, 28, 2, 30, 0), is_dst=False), + find(tz.localize(datetime(2018, 10, 28, 2, 5, 0), is_dst=False), + 2, 30, 0) + ) + + self.assertEqual( + tz.localize(datetime(2018, 10, 28, 2, 30, 0), is_dst=False), + find(tz.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=True), + 2, 30, 0) + ) + + self.assertEqual( + tz.localize(datetime(2018, 10, 28, 4, 30, 0), is_dst=False), + find(tz.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=True), + 4, 30, 0) + ) + + self.assertEqual( + tz.localize(datetime(2018, 10, 28, 2, 30, 0), is_dst=True), + find(tz.localize(datetime(2018, 10, 28, 2, 5, 0), is_dst=True), + 2, 30, 0) + ) + + self.assertEqual( + tz.localize(datetime(2018, 10, 29, 2, 30, 0)), + find(tz.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=False), + 2, 30, 0) + ) diff --git a/tests/util/test_volume.py b/tests/util/test_volume.py new file mode 100644 index 00000000000..e78e099d7d7 --- /dev/null +++ b/tests/util/test_volume.py @@ -0,0 +1,49 @@ +"""Test homeassistant volume utility functions.""" + +import unittest +import homeassistant.util.volume as volume_util +from homeassistant.const import (VOLUME_LITERS, VOLUME_MILLILITERS, + VOLUME_GALLONS, VOLUME_FLUID_OUNCE) + +INVALID_SYMBOL = 'bob' +VALID_SYMBOL = VOLUME_LITERS + + +class TestVolumeUtil(unittest.TestCase): + """Test the volume utility functions.""" + + def test_convert_same_unit(self): + """Test conversion from any unit to same unit.""" + self.assertEqual(2, volume_util.convert(2, VOLUME_LITERS, + VOLUME_LITERS)) + self.assertEqual(3, volume_util.convert(3, VOLUME_MILLILITERS, + VOLUME_MILLILITERS)) + self.assertEqual(4, volume_util.convert(4, VOLUME_GALLONS, + VOLUME_GALLONS)) + self.assertEqual(5, volume_util.convert(5, VOLUME_FLUID_OUNCE, + VOLUME_FLUID_OUNCE)) + + def test_convert_invalid_unit(self): + """Test exception is thrown for invalid units.""" + with self.assertRaises(ValueError): + volume_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL) + + with self.assertRaises(ValueError): + volume_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL) + + def test_convert_nonnumeric_value(self): + """Test exception is thrown for nonnumeric type.""" + with self.assertRaises(TypeError): + volume_util.convert('a', VOLUME_GALLONS, VOLUME_LITERS) + + def test_convert_from_liters(self): + """Test conversion from liters to other units.""" + liters = 5 + self.assertEqual(volume_util.convert(liters, VOLUME_LITERS, + VOLUME_GALLONS), 1.321) + + def test_convert_from_gallons(self): + """Test conversion from gallons to other units.""" + gallons = 5 + self.assertEqual(volume_util.convert(gallons, VOLUME_GALLONS, + VOLUME_LITERS), 18.925) diff --git a/virtualization/vagrant/Vagrantfile b/virtualization/vagrant/Vagrantfile index e50c4e6de00..d3974d51a7a 100644 --- a/virtualization/vagrant/Vagrantfile +++ b/virtualization/vagrant/Vagrantfile @@ -13,4 +13,12 @@ Vagrant.configure(2) do |config| vb.cpus = 2 vb.customize ['modifyvm', :id, '--memory', '1024'] end + config.vm.provider :hyperv do |h, override| + override.vm.box = "generic/debian9" + override.vm.hostname = "contrib-stretch" + h.vmname = "home-assistant" + h.cpus = 2 + h.memory = 1024 + h.maxmemory = 1024 + end end diff --git a/virtualization/vagrant/provision.bat b/virtualization/vagrant/provision.bat new file mode 100644 index 00000000000..c8174e939a1 --- /dev/null +++ b/virtualization/vagrant/provision.bat @@ -0,0 +1,50 @@ +@echo off +call:main %* +goto:eof + +:usage +echo.############################################################ +echo. +echo.Use `./provision.bat` to interact with HASS. E.g: +echo. +echo.- setup the environment: `./provision.bat start` +echo.- restart HASS process: `./provision.bat restart` +echo.- run test suit: `./provision.bat tests` +echo.- destroy the host and start anew: `./provision.bat recreate` +echo. +echo.Official documentation at https://home-assistant.io/docs/installation/vagrant/ +echo. +echo.############################################################' +goto:eof + +:main +if "%*"=="setup" ( + if exist setup_done del setup_done + vagrant up --provision + copy /y nul setup_done +) else ( +if "%*"=="tests" ( + copy /y nul run_tests + vagrant provision +) else ( +if "%*"=="restart" ( + copy /y nul restart + vagrant provision +) else ( +if "%*"=="start" ( + vagrant up --provision +) else ( +if "%*"=="stop" ( + vagrant halt +) else ( +if "%*"=="destroy" ( + vagrant destroy -f +) else ( +if "%*"=="recreate" ( + if exist setup_done del setup_done + if exist restart del restart + vagrant destroy -f + vagrant up --provision +) else ( + call:usage +))))))) diff --git a/virtualization/vagrant/provision.sh b/virtualization/vagrant/provision.sh index d4ef4e0b446..1d2eecddc73 100755 --- a/virtualization/vagrant/provision.sh +++ b/virtualization/vagrant/provision.sh @@ -76,6 +76,10 @@ run_tests() { rsync -a --delete \ --exclude='*.tox' \ --exclude='*.git' \ + --exclude='.vagrant' \ + --exclude='lib64' \ + --exclude='bin/python' \ + --exclude='bin/python3' \ /home-assistant/ /home-assistant-tests/ cd /home-assistant-tests && tox || true echo '############################################################'