diff --git a/.coveragerc b/.coveragerc index a18ec47601082..d5eb32e670c28 100644 --- a/.coveragerc +++ b/.coveragerc @@ -8,6 +8,9 @@ omit = homeassistant/helpers/signal.py # omit pieces of code that rely on external devices being present + homeassistant/components/abode.py + homeassistant/components/*/abode.py + homeassistant/components/alarmdecoder.py homeassistant/components/*/alarmdecoder.py @@ -167,6 +170,9 @@ omit = homeassistant/components/tellstick.py homeassistant/components/*/tellstick.py + homeassistant/components/tesla.py + homeassistant/components/*/tesla.py + homeassistant/components/*/thinkingcleaner.py homeassistant/components/tradfri.py @@ -176,6 +182,9 @@ omit = homeassistant/components/notify/twilio_sms.py homeassistant/components/notify/twilio_call.py + homeassistant/components/usps.py + homeassistant/components/*/usps.py + homeassistant/components/velbus.py homeassistant/components/*/velbus.py @@ -322,10 +331,12 @@ omit = homeassistant/components/light/tplink.py homeassistant/components/light/tradfri.py homeassistant/components/light/x10.py + homeassistant/components/light/xiaomi_philipslight.py homeassistant/components/light/yeelight.py homeassistant/components/light/yeelightsunflower.py homeassistant/components/light/zengge.py homeassistant/components/lirc.py + homeassistant/components/lock/nello.py homeassistant/components/lock/nuki.py homeassistant/components/lock/lockitron.py homeassistant/components/lock/sesame.py @@ -373,6 +384,8 @@ omit = homeassistant/components/media_player/vlc.py homeassistant/components/media_player/volumio.py homeassistant/components/media_player/yamaha.py + homeassistant/components/media_player/yamaha_musiccast.py + homeassistant/components/mycroft.py homeassistant/components/notify/aws_lambda.py homeassistant/components/notify/aws_sns.py homeassistant/components/notify/aws_sqs.py @@ -383,14 +396,17 @@ omit = homeassistant/components/notify/free_mobile.py homeassistant/components/notify/gntp.py homeassistant/components/notify/group.py + homeassistant/components/notify/hipchat.py homeassistant/components/notify/instapush.py homeassistant/components/notify/kodi.py homeassistant/components/notify/lannouncer.py homeassistant/components/notify/llamalab_automate.py homeassistant/components/notify/matrix.py homeassistant/components/notify/message_bird.py + homeassistant/components/notify/mycroft.py homeassistant/components/notify/nfandroidtv.py homeassistant/components/notify/nma.py + homeassistant/components/notify/prowl.py homeassistant/components/notify/pushbullet.py homeassistant/components/notify/pushetta.py homeassistant/components/notify/pushover.py @@ -411,6 +427,7 @@ omit = homeassistant/components/remote/itach.py homeassistant/components/scene/hunterdouglas_powerview.py homeassistant/components/scene/lifx_cloud.py + homeassistant/components/sensor/airvisual.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/arwn.py homeassistant/components/sensor/bbox.py @@ -436,6 +453,7 @@ omit = homeassistant/components/sensor/dovado.py homeassistant/components/sensor/dte_energy_bridge.py homeassistant/components/sensor/dublin_bus_transport.py + homeassistant/components/sensor/dwd_weather_warnings.py homeassistant/components/sensor/ebox.py homeassistant/components/sensor/eddystone_temperature.py homeassistant/components/sensor/eliqonline.py @@ -471,6 +489,7 @@ omit = homeassistant/components/sensor/metoffice.py homeassistant/components/sensor/miflora.py homeassistant/components/sensor/modem_callerid.py + homeassistant/components/sensor/mopar.py homeassistant/components/sensor/mqtt_room.py homeassistant/components/sensor/mvglive.py homeassistant/components/sensor/netdata.py @@ -508,6 +527,7 @@ omit = homeassistant/components/sensor/swiss_public_transport.py homeassistant/components/sensor/synologydsm.py homeassistant/components/sensor/systemmonitor.py + homeassistant/components/sensor/tank_utility.py homeassistant/components/sensor/ted5000.py homeassistant/components/sensor/temper.py homeassistant/components/sensor/time_date.py @@ -517,9 +537,9 @@ omit = homeassistant/components/sensor/uber.py homeassistant/components/sensor/upnp.py homeassistant/components/sensor/ups.py - homeassistant/components/sensor/usps.py homeassistant/components/sensor/vasttrafik.py homeassistant/components/sensor/waqi.py + homeassistant/components/sensor/worldtidesinfo.py homeassistant/components/sensor/xbox_live.py homeassistant/components/sensor/yweather.py homeassistant/components/sensor/zamg.py diff --git a/.gitignore b/.gitignore index 26efcc25b85f1..87bc6990ce4e4 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,6 @@ docs/build # Windows Explorer desktop.ini +/home-assistant.pyproj +/home-assistant.sln +/.vs/home-assistant/v14 diff --git a/README.rst b/README.rst index 039e8a922af82..7f0d41b00eab9 100644 --- a/README.rst +++ b/README.rst @@ -33,10 +33,6 @@ of a component, check the `Home Assistant help section ' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + for platform in ABODE_PLATFORMS: + discovery.load_platform(hass, platform, DOMAIN, {}, config) + + def logout(event): + """Logout of Abode.""" + abode.stop_listener() + abode.logout() + _LOGGER.info("Logged out of Abode") + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout) + + def startup(event): + """Listen for push events.""" + abode.start_listener() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, startup) + + return True + + +class AbodeDevice(Entity): + """Representation of an Abode device.""" + + def __init__(self, controller, device): + """Initialize a sensor for Abode device.""" + self._controller = controller + self._device = device + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe Abode events.""" + self.hass.async_add_job( + self._controller.register, self._device, + self._update_callback + ) + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the sensor.""" + return self._device.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._device.device_id, + 'battery_low': self._device.battery_low, + 'no_response': self._device.no_response + } + + def _update_callback(self, device): + """Update the device state.""" + self.schedule_update_ha_state() diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 39c86f3215f8c..005048ba8c130 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -13,7 +13,8 @@ from homeassistant.const import ( ATTR_CODE, ATTR_CODE_FORMAT, ATTR_ENTITY_ID, SERVICE_ALARM_TRIGGER, - SERVICE_ALARM_DISARM, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_AWAY) + SERVICE_ALARM_DISARM, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_NIGHT) from homeassistant.config import load_yaml_config_file from homeassistant.loader import bind_hass from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa @@ -31,6 +32,7 @@ SERVICE_ALARM_DISARM: 'alarm_disarm', SERVICE_ALARM_ARM_HOME: 'alarm_arm_home', SERVICE_ALARM_ARM_AWAY: 'alarm_arm_away', + SERVICE_ALARM_ARM_NIGHT: 'alarm_arm_night', SERVICE_ALARM_TRIGGER: 'alarm_trigger' } @@ -81,6 +83,18 @@ def alarm_arm_away(hass, code=None, entity_id=None): hass.services.call(DOMAIN, SERVICE_ALARM_ARM_AWAY, data) +@bind_hass +def alarm_arm_night(hass, code=None, entity_id=None): + """Send the alarm the command for arm night.""" + data = {} + if code: + data[ATTR_CODE] = code + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_ALARM_ARM_NIGHT, data) + + @bind_hass def alarm_trigger(hass, code=None, entity_id=None): """Send the alarm the command for trigger.""" @@ -187,6 +201,17 @@ def async_alarm_arm_away(self, code=None): """ return self.hass.async_add_job(self.alarm_arm_away, code) + def alarm_arm_night(self, code=None): + """Send arm night command.""" + raise NotImplementedError() + + def async_alarm_arm_night(self, code=None): + """Send arm night command. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.alarm_arm_night, code) + def alarm_trigger(self, code=None): """Send alarm trigger command.""" raise NotImplementedError() diff --git a/homeassistant/components/alarm_control_panel/abode.py b/homeassistant/components/alarm_control_panel/abode.py new file mode 100644 index 0000000000000..7a615ffc7bf1a --- /dev/null +++ b/homeassistant/components/alarm_control_panel/abode.py @@ -0,0 +1,76 @@ +""" +This component provides HA alarm_control_panel support for Abode System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.abode/ +""" +import logging + +from homeassistant.components.abode import ( + AbodeDevice, DATA_ABODE, DEFAULT_NAME, CONF_ATTRIBUTION) +from homeassistant.components.alarm_control_panel import (AlarmControlPanel) +from homeassistant.const import (ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + +ICON = 'mdi:security' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up a sensor for an Abode device.""" + abode = hass.data[DATA_ABODE] + + add_devices([AbodeAlarm(abode, abode.get_alarm())]) + + +class AbodeAlarm(AbodeDevice, AlarmControlPanel): + """An alarm_control_panel implementation for Abode.""" + + def __init__(self, controller, device): + """Initialize the alarm control panel.""" + AbodeDevice.__init__(self, controller, device) + self._name = "{0}".format(DEFAULT_NAME) + + @property + def icon(self): + """Return icon.""" + return ICON + + @property + def state(self): + """Return the state of the device.""" + if self._device.is_standby: + state = STATE_ALARM_DISARMED + elif self._device.is_away: + state = STATE_ALARM_ARMED_AWAY + elif self._device.is_home: + state = STATE_ALARM_ARMED_HOME + else: + state = None + return state + + def alarm_disarm(self, code=None): + """Send disarm command.""" + self._device.set_standby() + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + self._device.set_home() + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + self._device.set_away() + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._device.device_id, + 'battery_backup': self._device.battery, + 'cellular_backup': self._device.is_cellular + } diff --git a/homeassistant/components/alarm_control_panel/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index 8ea472a7b19dd..fbafe061334c0 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -18,7 +18,7 @@ CONF_NAME, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED) -REQUIREMENTS = ['pythonegardia==1.0.17'] +REQUIREMENTS = ['pythonegardia==1.0.20'] _LOGGER = logging.getLogger(__name__) @@ -29,7 +29,7 @@ DEFAULT_NAME = 'Egardia' DEFAULT_PORT = 80 DEFAULT_REPORT_SERVER_ENABLED = False -DEFAULT_REPORT_SERVER_PORT = 85 +DEFAULT_REPORT_SERVER_PORT = 52010 DOMAIN = 'egardia' NOTIFICATION_ID = 'egardia_notification' @@ -154,8 +154,9 @@ def parsestatus(self, status): def update(self): """Update the alarm status.""" - status = self._egardiasystem.getstate() - self.parsestatus(status) + if not self._rs_enabled: + status = self._egardiasystem.getstate() + self.parsestatus(status) def alarm_disarm(self, code=None): """Send disarm command.""" diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index c87aea862d540..f345ccc4dcdf1 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -12,9 +12,10 @@ import homeassistant.components.alarm_control_panel as alarm import homeassistant.util.dt as dt_util from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, CONF_PLATFORM, CONF_NAME, - CONF_CODE, CONF_PENDING_TIME, CONF_TRIGGER_TIME, CONF_DISARM_AFTER_TRIGGER) + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, + CONF_PLATFORM, CONF_NAME, CONF_CODE, CONF_PENDING_TIME, CONF_TRIGGER_TIME, + CONF_DISARM_AFTER_TRIGGER) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_point_in_time @@ -23,6 +24,8 @@ DEFAULT_TRIGGER_TIME = 120 DEFAULT_DISARM_AFTER_TRIGGER = False +ATTR_POST_PENDING_STATE = 'post_pending_state' + PLATFORM_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'manual', vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, @@ -87,7 +90,8 @@ def name(self): def state(self): """Return the state of the device.""" if self._state in (STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_AWAY) and \ + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_NIGHT) and \ self._pending_time and self._state_ts + self._pending_time > \ dt_util.utcnow(): return STATE_ALARM_PENDING @@ -99,7 +103,9 @@ def state(self): self._trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED - return self._pre_trigger_state + else: + self._state = self._pre_trigger_state + return self._state return self._state @@ -145,6 +151,20 @@ def alarm_arm_away(self, code=None): self._hass, self.async_update_ha_state, self._state_ts + self._pending_time) + def alarm_arm_night(self, code=None): + """Send arm night command.""" + if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT): + return + + self._state = STATE_ALARM_ARMED_NIGHT + self._state_ts = dt_util.utcnow() + self.schedule_update_ha_state() + + if self._pending_time: + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + self._pending_time) + def alarm_trigger(self, code=None): """Send alarm trigger command. No code needed.""" self._pre_trigger_state = self._state @@ -167,3 +187,13 @@ def _validate_code(self, code, state): if not check: _LOGGER.warning("Invalid code given for %s", state) return check + + @property + def device_state_attributes(self): + """Return the state attributes.""" + state_attr = {} + + if self.state == STATE_ALARM_PENDING: + state_attr[ATTR_POST_PENDING_STATE] = self._state + + return state_attr diff --git a/homeassistant/components/alarm_control_panel/services.yaml b/homeassistant/components/alarm_control_panel/services.yaml index 6cc3946ca66c8..19c3ca0233d44 100644 --- a/homeassistant/components/alarm_control_panel/services.yaml +++ b/homeassistant/components/alarm_control_panel/services.yaml @@ -31,6 +31,17 @@ alarm_arm_away: description: An optional code to arm away the alarm control panel with example: 1234 +alarm_arm_night: + description: Send the alarm the command for arm night + + fields: + entity_id: + description: Name of alarm control panel to arm night + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm night the alarm control panel with + example: 1234 + alarm_trigger: description: Send the alarm the command for trigger diff --git a/homeassistant/components/alarm_control_panel/simplisafe.py b/homeassistant/components/alarm_control_panel/simplisafe.py index 5eb2e9fe7d392..7f4e4dfa756a4 100644 --- a/homeassistant/components/alarm_control_panel/simplisafe.py +++ b/homeassistant/components/alarm_control_panel/simplisafe.py @@ -16,7 +16,7 @@ EVENT_HOMEASSISTANT_STOP) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['simplisafe-python==1.0.4'] +REQUIREMENTS = ['simplisafe-python==1.0.5'] _LOGGER = logging.getLogger(__name__) @@ -89,11 +89,11 @@ def code_format(self): def state(self): """Return the state of the device.""" status = self.simplisafe.state() - if status == 'Off': + if status == 'off': state = STATE_ALARM_DISARMED - elif status == 'Home': + elif status == 'home': state = STATE_ALARM_ARMED_HOME - elif status == 'Away': + elif status == 'away': state = STATE_ALARM_ARMED_AWAY else: state = STATE_UNKNOWN diff --git a/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant/components/alarm_control_panel/totalconnect.py index 9c0b5108feeea..05dc8aeef202c 100644 --- a/homeassistant/components/alarm_control_panel/totalconnect.py +++ b/homeassistant/components/alarm_control_panel/totalconnect.py @@ -13,8 +13,8 @@ from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_UNKNOWN, - CONF_NAME) + STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, + STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_UNKNOWN, CONF_NAME) REQUIREMENTS = ['total_connect_client==0.11'] @@ -74,6 +74,12 @@ def update(self): state = STATE_ALARM_ARMED_HOME elif status == self._client.ARMED_AWAY: state = STATE_ALARM_ARMED_AWAY + elif status == self._client.ARMED_STAY_NIGHT: + state = STATE_ALARM_ARMED_NIGHT + elif status == self._client.ARMING: + state = STATE_ALARM_ARMING + elif status == self._client.DISARMING: + state = STATE_ALARM_DISARMING else: state = STATE_UNKNOWN @@ -90,3 +96,7 @@ def alarm_arm_home(self, code=None): def alarm_arm_away(self, code=None): """Send arm away command.""" self._client.arm_away() + + def alarm_arm_night(self, code=None): + """Send arm night command.""" + self._client.arm_stay_night() diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index c5f40ca5db87f..7a2ff7610f7f5 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -91,7 +91,7 @@ def configuration_callback(callback_data): hass.async_add_job(configurator.request_done, instance) instance = configurator.request_config( - hass, 'Apple TV Authentication', configuration_callback, + 'Apple TV Authentication', configuration_callback, description='Please enter PIN code shown on screen.', submit_caption='Confirm', fields=[{'id': 'pin', 'name': 'PIN Code', 'type': 'password'}] diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 3657724f6791d..51b2ea89f0f1c 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -12,16 +12,18 @@ from homeassistant.core import callback from homeassistant.const import ( CONF_VALUE_TEMPLATE, CONF_PLATFORM, CONF_ENTITY_ID, - CONF_BELOW, CONF_ABOVE) -from homeassistant.helpers.event import async_track_state_change + CONF_BELOW, CONF_ABOVE, CONF_FOR) +from homeassistant.helpers.event import ( + async_track_state_change, async_track_same_state) from homeassistant.helpers import condition, config_validation as cv TRIGGER_SCHEMA = vol.All(vol.Schema({ vol.Required(CONF_PLATFORM): 'numeric_state', vol.Required(CONF_ENTITY_ID): cv.entity_ids, - CONF_BELOW: vol.Coerce(float), - CONF_ABOVE: vol.Coerce(float), + vol.Optional(CONF_BELOW): vol.Coerce(float), + vol.Optional(CONF_ABOVE): vol.Coerce(float), vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta), }), cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE)) _LOGGER = logging.getLogger(__name__) @@ -33,15 +35,18 @@ def async_trigger(hass, config, action): entity_id = config.get(CONF_ENTITY_ID) below = config.get(CONF_BELOW) above = config.get(CONF_ABOVE) + time_delta = config.get(CONF_FOR) value_template = config.get(CONF_VALUE_TEMPLATE) + async_remove_track_same = None + if value_template is not None: value_template.hass = hass @callback - def state_automation_listener(entity, from_s, to_s): - """Listen for state changes and calls action.""" + def check_numeric_state(entity, from_s, to_s): + """Return True if they should trigger.""" if to_s is None: - return + return False variables = { 'trigger': { @@ -55,17 +60,56 @@ def state_automation_listener(entity, from_s, to_s): # If new one doesn't match, nothing to do if not condition.async_numeric_state( hass, to_s, below, above, value_template, variables): + return False + + return True + + @callback + def state_automation_listener(entity, from_s, to_s): + """Listen for state changes and calls action.""" + nonlocal async_remove_track_same + + if not check_numeric_state(entity, from_s, to_s): return + variables = { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': entity, + 'below': below, + 'above': above, + 'from_state': from_s, + 'to_state': to_s, + } + } + # Only match if old didn't exist or existed but didn't match # Written as: skip if old one did exist and matched if from_s is not None and condition.async_numeric_state( hass, from_s, below, above, value_template, variables): return - variables['trigger']['from_state'] = from_s - variables['trigger']['to_state'] = to_s + @callback + def call_action(): + """Call action with right context.""" + hass.async_run_job(action, variables) + + if not time_delta: + call_action() + return + + async_remove_track_same = async_track_same_state( + hass, True, time_delta, call_action, entity_ids=entity_id, + async_check_func=check_numeric_state) + + unsub = async_track_state_change( + hass, entity_id, state_automation_listener) - hass.async_run_job(action, variables) + @callback + def async_remove(): + """Remove state listeners async.""" + unsub() + if async_remove_track_same: + async_remove_track_same() # pylint: disable=not-callable - return async_track_state_change(hass, entity_id, state_automation_listener) + return async_remove diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index 8ad5c40bb80d2..e7a01cb711582 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -8,28 +8,23 @@ import voluptuous as vol from homeassistant.core import callback -import homeassistant.util.dt as dt_util -from homeassistant.const import MATCH_ALL, CONF_PLATFORM +from homeassistant.const import MATCH_ALL, CONF_PLATFORM, CONF_FOR from homeassistant.helpers.event import ( - async_track_state_change, async_track_point_in_utc_time) + async_track_state_change, async_track_same_state) import homeassistant.helpers.config_validation as cv CONF_ENTITY_ID = 'entity_id' CONF_FROM = 'from' CONF_TO = 'to' -CONF_FOR = 'for' -TRIGGER_SCHEMA = vol.All( - vol.Schema({ - vol.Required(CONF_PLATFORM): 'state', - vol.Required(CONF_ENTITY_ID): cv.entity_ids, - # These are str on purpose. Want to catch YAML conversions - CONF_FROM: str, - CONF_TO: str, - CONF_FOR: vol.All(cv.time_period, cv.positive_timedelta), - }), - cv.key_dependency(CONF_FOR, CONF_TO), -) +TRIGGER_SCHEMA = vol.All(vol.Schema({ + vol.Required(CONF_PLATFORM): 'state', + vol.Required(CONF_ENTITY_ID): cv.entity_ids, + # These are str on purpose. Want to catch YAML conversions + vol.Optional(CONF_FROM): str, + vol.Optional(CONF_TO): str, + vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta), +}), cv.key_dependency(CONF_FOR, CONF_TO)) @asyncio.coroutine @@ -39,28 +34,15 @@ def async_trigger(hass, config, action): from_state = config.get(CONF_FROM, MATCH_ALL) to_state = config.get(CONF_TO, MATCH_ALL) time_delta = config.get(CONF_FOR) - async_remove_state_for_cancel = None - async_remove_state_for_listener = None match_all = (from_state == MATCH_ALL and to_state == MATCH_ALL) - - @callback - def clear_listener(): - """Clear all unsub listener.""" - nonlocal async_remove_state_for_cancel, async_remove_state_for_listener - - # pylint: disable=not-callable - if async_remove_state_for_listener is not None: - async_remove_state_for_listener() - async_remove_state_for_listener = None - if async_remove_state_for_cancel is not None: - async_remove_state_for_cancel() - async_remove_state_for_cancel = None + async_remove_track_same = None @callback def state_automation_listener(entity, from_s, to_s): """Listen for state changes and calls action.""" - nonlocal async_remove_state_for_cancel, async_remove_state_for_listener + nonlocal async_remove_track_same + @callback def call_action(): """Call action with right context.""" hass.async_run_job(action, { @@ -78,33 +60,12 @@ def call_action(): from_s.last_changed == to_s.last_changed): return - if time_delta is None: + if not time_delta: call_action() return - @callback - def state_for_listener(now): - """Fire on state changes after a delay and calls action.""" - nonlocal async_remove_state_for_listener - async_remove_state_for_listener = None - clear_listener() - call_action() - - @callback - def state_for_cancel_listener(entity, inner_from_s, inner_to_s): - """Fire on changes and cancel for listener if changed.""" - if inner_to_s.state == to_s.state: - return - clear_listener() - - # cleanup previous listener - clear_listener() - - async_remove_state_for_listener = async_track_point_in_utc_time( - hass, state_for_listener, dt_util.utcnow() + time_delta) - - async_remove_state_for_cancel = async_track_state_change( - hass, entity, state_for_cancel_listener) + async_remove_track_same = async_track_same_state( + hass, to_s.state, time_delta, call_action, entity_ids=entity_id) unsub = async_track_state_change( hass, entity_id, state_automation_listener, from_state, to_state) @@ -113,6 +74,7 @@ def state_for_cancel_listener(entity, inner_from_s, inner_to_s): def async_remove(): """Remove state listeners async.""" unsub() - clear_listener() + if async_remove_track_same: + async_remove_track_same() # pylint: disable=not-callable return async_remove diff --git a/homeassistant/components/axis.py b/homeassistant/components/axis.py index d83e07989e60d..eaf859376582c 100644 --- a/homeassistant/components/axis.py +++ b/homeassistant/components/axis.py @@ -110,7 +110,7 @@ def configuration_callback(callback_data): title = '{} ({})'.format(name, host) request_id = configurator.request_config( - hass, title, configuration_callback, + title, configuration_callback, description='Functionality: ' + str(AXIS_INCLUDE), entity_picture="/static/images/logo_axis.png", link_name='Axis platform documentation', diff --git a/homeassistant/components/binary_sensor/abode.py b/homeassistant/components/binary_sensor/abode.py new file mode 100644 index 0000000000000..d3b0d662a9466 --- /dev/null +++ b/homeassistant/components/binary_sensor/abode.py @@ -0,0 +1,61 @@ +""" +This component provides HA binary_sensor support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.binary_sensor import BinarySensorDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up a sensor for an Abode device.""" + abode = hass.data[DATA_ABODE] + + device_types = map_abode_device_class().keys() + + sensors = [] + for sensor in abode.get_devices(type_filter=device_types): + sensors.append(AbodeBinarySensor(abode, sensor)) + + add_devices(sensors) + + +def map_abode_device_class(): + """Map Abode device types to Home Assistant binary sensor class.""" + import abodepy.helpers.constants as CONST + + return { + CONST.DEVICE_GLASS_BREAK: 'connectivity', + CONST.DEVICE_KEYPAD: 'connectivity', + CONST.DEVICE_DOOR_CONTACT: 'opening', + CONST.DEVICE_STATUS_DISPLAY: 'connectivity', + CONST.DEVICE_MOTION_CAMERA: 'connectivity', + CONST.DEVICE_WATER_SENSOR: 'moisture' + } + + +class AbodeBinarySensor(AbodeDevice, BinarySensorDevice): + """A binary sensor implementation for Abode device.""" + + def __init__(self, controller, device): + """Initialize a sensor for Abode device.""" + AbodeDevice.__init__(self, controller, device) + self._device_class = map_abode_device_class().get(self._device.type) + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._device.is_on + + @property + def device_class(self): + """Return the class of the binary sensor.""" + return self._device_class diff --git a/homeassistant/components/binary_sensor/bayesian.py b/homeassistant/components/binary_sensor/bayesian.py new file mode 100644 index 0000000000000..4c62735a6f9fb --- /dev/null +++ b/homeassistant/components/binary_sensor/bayesian.py @@ -0,0 +1,211 @@ +""" +Use Bayesian Inference to trigger a binary sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.bayesian/ +""" +import asyncio +import logging +from collections import OrderedDict + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_ABOVE, CONF_BELOW, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_NAME, + CONF_PLATFORM, CONF_STATE, STATE_UNKNOWN) +from homeassistant.core import callback +from homeassistant.helpers import condition +from homeassistant.helpers.event import async_track_state_change + +_LOGGER = logging.getLogger(__name__) + +CONF_OBSERVATIONS = 'observations' +CONF_PRIOR = 'prior' +CONF_PROBABILITY_THRESHOLD = 'probability_threshold' +CONF_P_GIVEN_F = 'prob_given_false' +CONF_P_GIVEN_T = 'prob_given_true' +CONF_TO_STATE = 'to_state' + +DEFAULT_NAME = 'BayesianBinary' + +NUMERIC_STATE_SCHEMA = vol.Schema({ + CONF_PLATFORM: 'numeric_state', + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_ABOVE): vol.Coerce(float), + vol.Optional(CONF_BELOW): vol.Coerce(float), + vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), + vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float) +}, required=True) + +STATE_SCHEMA = vol.Schema({ + CONF_PLATFORM: CONF_STATE, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TO_STATE): cv.string, + vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), + vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float) +}, required=True) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): + cv.string, + vol.Optional(CONF_DEVICE_CLASS): cv.string, + vol.Required(CONF_OBSERVATIONS): vol.Schema( + vol.All(cv.ensure_list, [vol.Any(NUMERIC_STATE_SCHEMA, + STATE_SCHEMA)]) + ), + vol.Required(CONF_PRIOR): vol.Coerce(float), + vol.Optional(CONF_PROBABILITY_THRESHOLD): + vol.Coerce(float), +}) + + +def update_probability(prior, prob_true, prob_false): + """Update probability using Bayes' rule.""" + numerator = prob_true * prior + denominator = numerator + prob_false * (1 - prior) + + probability = numerator / denominator + return probability + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Threshold sensor.""" + name = config.get(CONF_NAME) + observations = config.get(CONF_OBSERVATIONS) + prior = config.get(CONF_PRIOR) + probability_threshold = config.get(CONF_PROBABILITY_THRESHOLD, 0.5) + device_class = config.get(CONF_DEVICE_CLASS) + + async_add_devices([ + BayesianBinarySensor(name, prior, observations, probability_threshold, + device_class) + ], True) + + +class BayesianBinarySensor(BinarySensorDevice): + """Representation of a Bayesian sensor.""" + + def __init__(self, name, prior, observations, probability_threshold, + device_class): + """Initialize the Bayesian sensor.""" + self._name = name + self._observations = observations + self._probability_threshold = probability_threshold + self._device_class = device_class + self._deviation = False + self.prior = prior + self.probability = prior + + self.current_obs = OrderedDict({}) + + self.entity_obs = {obs['entity_id']: obs for obs in self._observations} + + self.watchers = { + 'numeric_state': self._process_numeric_state, + 'state': self._process_state + } + + @asyncio.coroutine + def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + @callback + # pylint: disable=invalid-name + def async_threshold_sensor_state_listener(entity, old_state, + new_state): + """Handle sensor state changes.""" + if new_state.state == STATE_UNKNOWN: + return + + entity_obs = self.entity_obs[entity] + platform = entity_obs['platform'] + + self.watchers[platform](entity_obs) + + prior = self.prior + print(self.current_obs.values()) + for obs in self.current_obs.values(): + prior = update_probability(prior, obs['prob_true'], + obs['prob_false']) + + self.probability = prior + + self.hass.async_add_job(self.async_update_ha_state, True) + + entities = [obs['entity_id'] for obs in self._observations] + async_track_state_change( + self.hass, entities, async_threshold_sensor_state_listener) + + def _update_current_obs(self, entity_observation, should_trigger): + """Update current observation.""" + entity = entity_observation['entity_id'] + + if should_trigger: + prob_true = entity_observation['prob_given_true'] + prob_false = entity_observation.get( + 'prob_given_false', 1 - prob_true) + + self.current_obs[entity] = { + 'prob_true': prob_true, + 'prob_false': prob_false + } + + else: + self.current_obs.pop(entity, None) + + def _process_numeric_state(self, entity_observation): + """Add entity to current_obs if numeric state conditions are met.""" + entity = entity_observation['entity_id'] + + should_trigger = condition.async_numeric_state( + self.hass, entity, + entity_observation.get('below'), + entity_observation.get('above'), None, entity_observation) + + self._update_current_obs(entity_observation, should_trigger) + + def _process_state(self, entity_observation): + """Add entity to current observations if state conditions are met.""" + entity = entity_observation['entity_id'] + + should_trigger = condition.state( + self.hass, entity, entity_observation.get('to_state')) + + self._update_current_obs(entity_observation, should_trigger) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._deviation + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_class(self): + """Return the sensor class of the sensor.""" + return self._device_class + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + 'observations': [val for val in self.current_obs.values()], + 'probability': self.probability, + 'probability_threshold': self._probability_threshold + } + + @asyncio.coroutine + def async_update(self): + """Get the latest data and update the states.""" + self._deviation = bool(self.probability > self._probability_threshold) diff --git a/homeassistant/components/binary_sensor/hikvision.py b/homeassistant/components/binary_sensor/hikvision.py index 7f2127fcad5f1..df488cc0ed600 100644 --- a/homeassistant/components/binary_sensor/hikvision.py +++ b/homeassistant/components/binary_sensor/hikvision.py @@ -18,7 +18,7 @@ CONF_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE) -REQUIREMENTS = ['pyhik==0.1.3'] +REQUIREMENTS = ['pyhik==0.1.4'] _LOGGER = logging.getLogger(__name__) CONF_IGNORED = 'ignored' @@ -47,6 +47,7 @@ 'PIR Alarm': 'motion', 'Face Detection': 'motion', 'Scene Change Detection': 'motion', + 'I/O': None, } CUSTOMIZE_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/binary_sensor/homematic.py b/homeassistant/components/binary_sensor/homematic.py index a82431a5ab880..2f464bc73cc58 100644 --- a/homeassistant/components/binary_sensor/homematic.py +++ b/homeassistant/components/binary_sensor/homematic.py @@ -35,8 +35,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMBinarySensor(hass, conf) - new_device.link_homematic() + new_device = HMBinarySensor(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index 87f8a30d78c55..2b11c3fe172ee 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -1,21 +1,145 @@ """ -Contains functionality to use a KNX group address as a binary. +Support for KNX/IP binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.knx/ """ -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.knx import (KNXConfig, KNXGroupAddress) +import asyncio +import voluptuous as vol +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES, \ + KNXAutomation +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, \ + BinarySensorDevice +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +CONF_ADDRESS = 'address' +CONF_DEVICE_CLASS = 'device_class' +CONF_SIGNIFICANT_BIT = 'significant_bit' +CONF_DEFAULT_SIGNIFICANT_BIT = 1 +CONF_AUTOMATION = 'automation' +CONF_HOOK = 'hook' +CONF_DEFAULT_HOOK = 'on' +CONF_COUNTER = 'counter' +CONF_DEFAULT_COUNTER = 1 +CONF_ACTION = 'action' + +CONF__ACTION = 'turn_off_action' + +DEFAULT_NAME = 'KNX Binary Sensor' DEPENDENCIES = ['knx'] +AUTOMATION_SCHEMA = vol.Schema({ + vol.Optional(CONF_HOOK, default=CONF_DEFAULT_HOOK): cv.string, + vol.Optional(CONF_COUNTER, default=CONF_DEFAULT_COUNTER): cv.port, + vol.Required(CONF_ACTION, default=None): cv.SCRIPT_SCHEMA +}) + +AUTOMATIONS_SCHEMA = vol.All( + cv.ensure_list, + [AUTOMATION_SCHEMA] +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): cv.string, + vol.Optional(CONF_SIGNIFICANT_BIT, default=CONF_DEFAULT_SIGNIFICANT_BIT): + cv.positive_int, + vol.Optional(CONF_AUTOMATION, default=None): AUTOMATIONS_SCHEMA, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up binary sensor(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True + + +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up binary sensors for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXBinarySensor(hass, device)) + add_devices(entities) + + +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up binary senor for KNX platform configured within plattform.""" + name = config.get(CONF_NAME) + import xknx + binary_sensor = xknx.devices.BinarySensor( + hass.data[DATA_KNX].xknx, + name=name, + group_address=config.get(CONF_ADDRESS), + device_class=config.get(CONF_DEVICE_CLASS), + significant_bit=config.get(CONF_SIGNIFICANT_BIT)) + hass.data[DATA_KNX].xknx.devices.add(binary_sensor) + + entity = KNXBinarySensor(hass, binary_sensor) + automations = config.get(CONF_AUTOMATION) + if automations is not None: + for automation in automations: + counter = automation.get(CONF_COUNTER) + hook = automation.get(CONF_HOOK) + action = automation.get(CONF_ACTION) + entity.automations.append(KNXAutomation( + hass=hass, device=binary_sensor, hook=hook, + action=action, counter=counter)) + add_devices([entity]) + + +class KNXBinarySensor(BinarySensorDevice): + """Representation of a KNX binary sensor.""" + + def __init__(self, hass, device): + """Initialization of KNXBinarySensor.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + self.automations = [] + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX binary sensor platform.""" - add_devices([KNXSwitch(hass, KNXConfig(config))]) + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name + @property + def should_poll(self): + """No polling needed within KNX.""" + return False -class KNXSwitch(KNXGroupAddress, BinarySensorDevice): - """Representation of a KNX binary sensor device.""" + @property + def device_class(self): + """Return the class of this sensor.""" + return self.device.device_class - pass + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.device.is_on() diff --git a/homeassistant/components/binary_sensor/mysensors.py b/homeassistant/components/binary_sensor/mysensors.py index 767ed858ec75e..4b83f0c8f2df1 100644 --- a/homeassistant/components/binary_sensor/mysensors.py +++ b/homeassistant/components/binary_sensor/mysensors.py @@ -4,62 +4,27 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.mysensors/ """ -import logging - from homeassistant.components import mysensors -from homeassistant.components.binary_sensor import (DEVICE_CLASSES, +from homeassistant.components.binary_sensor import (DEVICE_CLASSES, DOMAIN, BinarySensorDevice) from homeassistant.const import STATE_ON -_LOGGER = logging.getLogger(__name__) -DEPENDENCIES = [] - def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the MySensors platform for sensors.""" - # Only act if loaded via mysensors by discovery event. - # Otherwise gateway is not setup. - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - # Define the S_TYPES and V_TYPES that the platform should handle as - # states. Map them in a dict of lists. - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_DOOR: [set_req.V_TRIPPED], - pres.S_MOTION: [set_req.V_TRIPPED], - pres.S_SMOKE: [set_req.V_TRIPPED], - } - if float(gateway.protocol_version) >= 1.5: - map_sv_types.update({ - pres.S_SPRINKLER: [set_req.V_TRIPPED], - pres.S_WATER_LEAK: [set_req.V_TRIPPED], - pres.S_SOUND: [set_req.V_TRIPPED], - pres.S_VIBRATION: [set_req.V_TRIPPED], - pres.S_MOISTURE: [set_req.V_TRIPPED], - }) - - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, MySensorsBinarySensor, add_devices)) + """Setup the mysensors platform for binary sensors.""" + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsBinarySensor, + add_devices=add_devices) class MySensorsBinarySensor( - mysensors.MySensorsDeviceEntity, BinarySensorDevice): + mysensors.MySensorsEntity, BinarySensorDevice): """Represent the value of a MySensors Binary Sensor child node.""" @property def is_on(self): """Return True if the binary sensor is on.""" - if self.value_type in self._values: - return self._values[self.value_type] == STATE_ON - return False + return self._values.get(self.value_type) == STATE_ON @property def device_class(self): diff --git a/homeassistant/components/binary_sensor/ring.py b/homeassistant/components/binary_sensor/ring.py index 429e92afa7fc3..5c9a644f6b78d 100644 --- a/homeassistant/components/binary_sensor/ring.py +++ b/homeassistant/components/binary_sensor/ring.py @@ -103,7 +103,8 @@ def update(self): self._data.check_alerts() if self._data.alert: - self._state = (self._sensor_type == - self._data.alert.get('kind')) + if self._sensor_type == self._data.alert.get('kind') and \ + self._data.account_id == self._data.alert.get('doorbot_id'): + self._state = True else: self._state = False diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index 330e8eaea9d22..413804f085667 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -19,16 +19,24 @@ from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.event import ( + async_track_state_change, async_track_same_state) from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) +CONF_DELAY_ON = 'delay_on' +CONF_DELAY_OFF = 'delay_off' + SENSOR_SCHEMA = vol.Schema({ vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(ATTR_FRIENDLY_NAME): cv.string, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_DELAY_ON): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_DELAY_OFF): + vol.All(cv.time_period, cv.positive_timedelta), }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -47,6 +55,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): value_template.extract_entities()) friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) device_class = device_config.get(CONF_DEVICE_CLASS) + delay_on = device_config.get(CONF_DELAY_ON) + delay_off = device_config.get(CONF_DELAY_OFF) if value_template is not None: value_template.hass = hass @@ -54,13 +64,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): sensors.append( BinarySensorTemplate( hass, device, friendly_name, device_class, value_template, - entity_ids) + entity_ids, delay_on, delay_off) ) if not sensors: _LOGGER.error("No sensors added") return False - async_add_devices(sensors, True) + async_add_devices(sensors) return True @@ -68,7 +78,7 @@ class BinarySensorTemplate(BinarySensorDevice): """A virtual binary sensor that triggers from another sensor.""" def __init__(self, hass, device, friendly_name, device_class, - value_template, entity_ids): + value_template, entity_ids, delay_on, delay_off): """Initialize the Template binary sensor.""" self.hass = hass self.entity_id = async_generate_entity_id( @@ -78,6 +88,8 @@ def __init__(self, hass, device, friendly_name, device_class, self._template = value_template self._state = None self._entities = entity_ids + self._delay_on = delay_on + self._delay_off = delay_off @asyncio.coroutine def async_added_to_hass(self): @@ -89,7 +101,7 @@ def async_added_to_hass(self): @callback def template_bsensor_state_listener(entity, old_state, new_state): """Handle the target device state changes.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_check_state() @callback def template_bsensor_startup(event): @@ -97,7 +109,7 @@ def template_bsensor_startup(event): async_track_state_change( self.hass, self._entities, template_bsensor_state_listener) - self.hass.async_add_job(self.async_update_ha_state(True)) + self.hass.async_add_job(self.async_check_state) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_bsensor_startup) @@ -122,11 +134,11 @@ def should_poll(self): """No polling needed.""" return False - @asyncio.coroutine - def async_update(self): - """Update the state from the template.""" + @callback + def _async_render(self, *args): + """Get the state of template.""" try: - self._state = self._template.async_render().lower() == 'true' + return self._template.async_render().lower() == 'true' except TemplateError as ex: if ex.args and ex.args[0].startswith( "UndefinedError: 'None' has no attribute"): @@ -135,4 +147,29 @@ def async_update(self): "the state is unknown", self._name) return _LOGGER.error("Could not render template %s: %s", self._name, ex) - self._state = False + + @callback + def async_check_state(self): + """Update the state from the template.""" + state = self._async_render() + + # return if the state don't change or is invalid + if state is None or state == self.state: + return + + @callback + def set_state(): + """Set state of template binary sensor.""" + self._state = state + self.hass.async_add_job(self.async_update_ha_state()) + + # state without delay + if (state and not self._delay_on) or \ + (not state and not self._delay_off): + set_state() + return + + period = self._delay_on if state else self._delay_off + async_track_same_state( + self.hass, state, period, set_state, entity_ids=self._entities, + async_check_func=self._async_render) diff --git a/homeassistant/components/binary_sensor/tesla.py b/homeassistant/components/binary_sensor/tesla.py new file mode 100644 index 0000000000000..af7e394b50e11 --- /dev/null +++ b/homeassistant/components/binary_sensor/tesla.py @@ -0,0 +1,57 @@ +""" +Support for Tesla binary sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.tesla/ +""" +import logging + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, ENTITY_ID_FORMAT) +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla binary sensor.""" + devices = [ + TeslaBinarySensor( + device, hass.data[TESLA_DOMAIN]['controller'], 'connectivity') + for device in hass.data[TESLA_DOMAIN]['devices']['binary_sensor']] + add_devices(devices, True) + + +class TeslaBinarySensor(TeslaDevice, BinarySensorDevice): + """Implement an Tesla binary sensor for parking and charger.""" + + def __init__(self, tesla_device, controller, sensor_type): + """Initialisation of binary sensor.""" + super().__init__(tesla_device, controller) + self._name = self.tesla_device.name + self._state = False + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + self._sensor_type = sensor_type + + @property + def device_class(self): + """Return the class of this binary sensor.""" + return self._sensor_type + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the binary sensor.""" + return self._state + + def update(self): + """Update the state of the device.""" + _LOGGER.debug("Updating sensor: %s", self._name) + self.tesla_device.update() + self._state = self.tesla_device.get_value() diff --git a/homeassistant/components/binary_sensor/workday.py b/homeassistant/components/binary_sensor/workday.py index 81cc8fd8798e2..f48525d41a8a1 100644 --- a/homeassistant/components/binary_sensor/workday.py +++ b/homeassistant/components/binary_sensor/workday.py @@ -6,13 +6,12 @@ """ import asyncio import logging -import datetime +from datetime import datetime, timedelta import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_NAME, WEEKDAYS -import homeassistant.util.dt as dt_util from homeassistant.components.binary_sensor import BinarySensorDevice import homeassistant.helpers.config_validation as cv @@ -39,11 +38,14 @@ DEFAULT_EXCLUDES = ['sat', 'sun', 'holiday'] DEFAULT_NAME = 'Workday Sensor' ALLOWED_DAYS = WEEKDAYS + ['holiday'] +CONF_OFFSET = 'days_offset' +DEFAULT_OFFSET = 0 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_COUNTRY): vol.In(ALL_COUNTRIES), vol.Optional(CONF_PROVINCE, default=None): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): vol.Coerce(int), vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): vol.All(cv.ensure_list, [vol.In(ALLOWED_DAYS)]), vol.Optional(CONF_EXCLUDES, default=DEFAULT_EXCLUDES): @@ -60,8 +62,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): province = config.get(CONF_PROVINCE) workdays = config.get(CONF_WORKDAYS) excludes = config.get(CONF_EXCLUDES) + days_offset = config.get(CONF_OFFSET) - year = datetime.datetime.now().year + year = (datetime.now() + timedelta(days=days_offset)).year obj_holidays = getattr(holidays, country)(years=year) if province: @@ -85,7 +88,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.debug("%s %s", date, name) add_devices([IsWorkdaySensor( - obj_holidays, workdays, excludes, sensor_name)], True) + obj_holidays, workdays, excludes, days_offset, sensor_name)], True) def day_to_string(day): @@ -99,12 +102,13 @@ def day_to_string(day): class IsWorkdaySensor(BinarySensorDevice): """Implementation of a Workday sensor.""" - def __init__(self, obj_holidays, workdays, excludes, name): + def __init__(self, obj_holidays, workdays, excludes, days_offset, name): """Initialize the Workday sensor.""" self._name = name self._obj_holidays = obj_holidays self._workdays = workdays self._excludes = excludes + self._days_offset = days_offset self._state = None @property @@ -135,6 +139,16 @@ def is_exclude(self, day, now): return False + @property + def state_attributes(self): + """Return the attributes of the entity.""" + # return self._attributes + return { + CONF_WORKDAYS: self._workdays, + CONF_EXCLUDES: self._excludes, + CONF_OFFSET: self._days_offset + } + @asyncio.coroutine def async_update(self): """Get date and look whether it is a holiday.""" @@ -142,11 +156,12 @@ def async_update(self): self._state = False # Get iso day of the week (1 = Monday, 7 = Sunday) - day = datetime.datetime.today().isoweekday() - 1 + date = datetime.today() + timedelta(days=self._days_offset) + day = date.isoweekday() - 1 day_of_week = day_to_string(day) - if self.is_include(day_of_week, dt_util.now()): + if self.is_include(day_of_week, date): self._state = True - if self.is_exclude(day_of_week, dt_util.now()): + if self.is_exclude(day_of_week, date): self._state = False diff --git a/homeassistant/components/binary_sensor/xiaomi.py b/homeassistant/components/binary_sensor/xiaomi.py index fafdc098c5d1a..c5f0a7b3dce24 100644 --- a/homeassistant/components/binary_sensor/xiaomi.py +++ b/homeassistant/components/binary_sensor/xiaomi.py @@ -31,6 +31,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices.append(XiaomiDoorSensor(device, gateway)) elif model == 'sensor_magnet.aq2': devices.append(XiaomiDoorSensor(device, gateway)) + elif model == 'sensor_wleak.aq1': + devices.append(XiaomiWaterLeakSensor(device, gateway)) elif model == 'smoke': devices.append(XiaomiSmokeSensor(device, gateway)) elif model == 'natgas': @@ -214,6 +216,35 @@ def parse_data(self, data): return False +class XiaomiWaterLeakSensor(XiaomiBinarySensor): + """Representation of a XiaomiWaterLeakSensor.""" + + def __init__(self, device, xiaomi_hub): + """Initialize the XiaomiWaterLeakSensor.""" + XiaomiBinarySensor.__init__(self, device, 'Water Leak Sensor', + xiaomi_hub, 'status', 'moisture') + + def parse_data(self, data): + """Parse data sent by gateway.""" + self._should_poll = False + + value = data.get(self._data_key) + if value is None: + return False + + if value == 'leak': + self._should_poll = True + if self._state: + return False + self._state = True + return True + elif value == 'no_leak': + if self._state: + self._state = False + return True + return False + + class XiaomiSmokeSensor(XiaomiBinarySensor): """Representation of a XiaomiSmokeSensor.""" diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py index 15138e2c2531e..8ea90d5a44e27 100644 --- a/homeassistant/components/camera/foscam.py +++ b/homeassistant/components/camera/foscam.py @@ -53,7 +53,7 @@ def __init__(self, device_info): self._name = device_info.get(CONF_NAME) self._motion_status = False - from foscam import FoscamCamera + from foscam.foscam import FoscamCamera self._foscam_session = FoscamCamera(ip_address, port, self._username, self._password, verbose=False) diff --git a/homeassistant/components/camera/usps.py b/homeassistant/components/camera/usps.py new file mode 100644 index 0000000000000..545ea9798de4a --- /dev/null +++ b/homeassistant/components/camera/usps.py @@ -0,0 +1,94 @@ +""" +Support for a camera made up of usps mail images. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/camera.usps/ +""" +from datetime import timedelta +import logging + +from homeassistant.components.camera import Camera +from homeassistant.components.usps import DATA_USPS + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['usps'] + +SCAN_INTERVAL = timedelta(seconds=10) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up USPS mail camera.""" + if discovery_info is None: + return + + usps = hass.data[DATA_USPS] + add_devices([USPSCamera(usps)]) + + +class USPSCamera(Camera): + """Representation of the images available from USPS.""" + + def __init__(self, usps): + """Initialize the USPS camera images.""" + super().__init__() + + self._usps = usps + self._name = self._usps.name + self._session = self._usps.session + + self._mail_img = [] + self._last_mail = None + self._mail_index = 0 + self._mail_count = 0 + + self._timer = None + + def camera_image(self): + """Update the camera's image if it has changed.""" + self._usps.update() + try: + self._mail_count = len(self._usps.mail) + except TypeError: + # No mail + return None + + if self._usps.mail != self._last_mail: + # Mail items must have changed + self._mail_img = [] + if len(self._usps.mail) >= 1: + self._last_mail = self._usps.mail + for article in self._usps.mail: + _LOGGER.debug("Fetching article image: %s", article) + img = self._session.get(article['image']).content + self._mail_img.append(img) + + try: + return self._mail_img[self._mail_index] + except IndexError: + return None + + @property + def name(self): + """Return the name of this camera.""" + return '{} mail'.format(self._name) + + @property + def model(self): + """Return date of mail as model.""" + try: + return 'Date: {}'.format(self._usps.mail[0]['date']) + except IndexError: + return None + + @property + def should_poll(self): + """Update the mail image index periodically.""" + return True + + def update(self): + """Update mail image index.""" + if self._mail_index < (self._mail_count - 1): + self._mail_index += 1 + else: + self._mail_index = 0 diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py index 60cda24eef9c1..ce6e9580e54fc 100644 --- a/homeassistant/components/climate/homematic.py +++ b/homeassistant/components/climate/homematic.py @@ -47,8 +47,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMThermostat(hass, conf) - new_device.link_homematic() + new_device = HMThermostat(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index 4ff87aa67ab5b..0b2df903e172f 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -196,6 +196,11 @@ def update(self): if val['id'] == self._id: data = val + except KeyError: + _LOGGER.error("Update failed from Honeywell server") + self.client.user_data = None + return + except StopIteration: _LOGGER.error("Did not receive any temperature data from the " "evohomeclient API") diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index e399e2f3dcae1..688ded5e7c4bb 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -1,68 +1,136 @@ """ -Support for KNX thermostats. +Support for KNX/IP climate devices. -For more details about this platform, please refer to the documentation +For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.knx/ """ -import logging - +import asyncio import voluptuous as vol -from homeassistant.components.climate import (ClimateDevice, PLATFORM_SCHEMA) -from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice) -from homeassistant.const import (CONF_NAME, TEMP_CELSIUS, ATTR_TEMPERATURE) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice +from homeassistant.const import CONF_NAME, TEMP_CELSIUS, ATTR_TEMPERATURE +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) - -CONF_ADDRESS = 'address' CONF_SETPOINT_ADDRESS = 'setpoint_address' CONF_TEMPERATURE_ADDRESS = 'temperature_address' - -DEFAULT_NAME = 'KNX Thermostat' +CONF_TARGET_TEMPERATURE_ADDRESS = 'target_temperature_address' +CONF_OPERATION_MODE_ADDRESS = 'operation_mode_address' +CONF_OPERATION_MODE_STATE_ADDRESS = 'operation_mode_state_address' +CONF_CONTROLLER_STATUS_ADDRESS = 'controller_status_address' +CONF_CONTROLLER_STATUS_STATE_ADDRESS = 'controller_status_state_address' +CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS = \ + 'operation_mode_frost_protection_address' +CONF_OPERATION_MODE_NIGHT_ADDRESS = 'operation_mode_night_address' +CONF_OPERATION_MODE_COMFORT_ADDRESS = 'operation_mode_comfort_address' + +DEFAULT_NAME = 'KNX Climate' DEPENDENCIES = ['knx'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_SETPOINT_ADDRESS): cv.string, vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_STATE_ADDRESS): cv.string, + vol.Optional(CONF_CONTROLLER_STATUS_ADDRESS): cv.string, + vol.Optional(CONF_CONTROLLER_STATUS_STATE_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_NIGHT_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_COMFORT_ADDRESS): cv.string, }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Create and add an entity based on the configuration.""" - add_devices([KNXThermostat(hass, KNXConfig(config))]) - - -class KNXThermostat(KNXMultiAddressDevice, ClimateDevice): - """Representation of a KNX thermostat. - - A KNX thermostat will has the following parameters: - - temperature (current temperature) - - setpoint (target temperature in HASS terms) - - operation mode selection (comfort/night/frost protection) - - This version supports only polling. Messages from the KNX bus do not - automatically update the state of the thermostat (to be implemented - in future releases) - """ - - def __init__(self, hass, config): - """Initialize the thermostat based on the given configuration.""" - KNXMultiAddressDevice.__init__( - self, hass, config, ['temperature', 'setpoint'], ['mode']) - - self._unit_of_measurement = TEMP_CELSIUS # KNX always used celsius +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up climate(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True + + +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up climates for KNX platform configured within plattform.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXClimate(hass, device)) + add_devices(entities) + + +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up climate for KNX platform configured within plattform.""" + import xknx + climate = xknx.devices.Climate( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address_temperature=config.get( + CONF_TEMPERATURE_ADDRESS), + group_address_target_temperature=config.get( + CONF_TARGET_TEMPERATURE_ADDRESS), + group_address_setpoint=config.get( + CONF_SETPOINT_ADDRESS), + group_address_operation_mode=config.get( + CONF_OPERATION_MODE_ADDRESS), + group_address_operation_mode_state=config.get( + CONF_OPERATION_MODE_STATE_ADDRESS), + group_address_controller_status=config.get( + CONF_CONTROLLER_STATUS_ADDRESS), + group_address_controller_status_state=config.get( + CONF_CONTROLLER_STATUS_STATE_ADDRESS), + group_address_operation_mode_protection=config.get( + CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS), + group_address_operation_mode_night=config.get( + CONF_OPERATION_MODE_NIGHT_ADDRESS), + group_address_operation_mode_comfort=config.get( + CONF_OPERATION_MODE_COMFORT_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(climate) + add_devices([KNXClimate(hass, climate)]) + + +class KNXClimate(ClimateDevice): + """Representation of a KNX climate.""" + + def __init__(self, hass, device): + """Initialization of KNXClimate.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + + self._unit_of_measurement = TEMP_CELSIUS self._away = False # not yet supported self._is_fan_on = False # not yet supported - self._current_temp = None - self._target_temp = None + + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name @property def should_poll(self): - """Return the polling state, is needed for the KNX thermostat.""" - return True + """No polling needed within KNX.""" + return False @property def temperature_unit(self): @@ -72,32 +140,42 @@ def temperature_unit(self): @property def current_temperature(self): """Return the current temperature.""" - return self._current_temp + return self.device.temperature @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._target_temp + if self.device.supports_target_temperature: + return self.device.target_temperature + return None - def set_temperature(self, **kwargs): + @asyncio.coroutine + def async_set_temperature(self, **kwargs): """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - from knxip.conversion import float_to_knx2 + if self.device.supports_target_temperature: + yield from self.device.set_target_temperature(temperature) - self.set_value('setpoint', float_to_knx2(temperature)) - _LOGGER.debug("Set target temperature to %s", temperature) + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + if self.device.supports_operation_mode: + return self.device.operation_mode.value + return None - def set_operation_mode(self, operation_mode): + @property + def operation_list(self): + """Return the list of available operation modes.""" + return [operation_mode.value for + operation_mode in + self.device.get_supported_operation_modes()] + + @asyncio.coroutine + def async_set_operation_mode(self, operation_mode): """Set operation mode.""" - raise NotImplementedError() - - def update(self): - """Update KNX climate.""" - from knxip.conversion import knx2_to_float - - super().update() - - self._current_temp = knx2_to_float(self.value('temperature')) - self._target_temp = knx2_to_float(self.value('setpoint')) + if self.device.supports_operation_mode: + from xknx.knx import HVACOperationMode + knx_operation_mode = HVACOperationMode(operation_mode) + yield from self.device.set_operation_mode(knx_operation_mode) diff --git a/homeassistant/components/climate/mysensors.py b/homeassistant/components/climate/mysensors.py index 82ed8a94e2baf..d4316c2cfbaf1 100755 --- a/homeassistant/components/climate/mysensors.py +++ b/homeassistant/components/climate/mysensors.py @@ -4,15 +4,11 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/climate.mysensors/ """ -import logging - from homeassistant.components import mysensors from homeassistant.components.climate import ( - STATE_COOL, STATE_HEAT, STATE_OFF, STATE_AUTO, ClimateDevice, - ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW) -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE - -_LOGGER = logging.getLogger(__name__) + ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN, STATE_AUTO, + STATE_COOL, STATE_HEAT, STATE_OFF, ClimateDevice) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT DICT_HA_TO_MYS = { STATE_AUTO: 'AutoChangeOver', @@ -29,28 +25,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the mysensors climate.""" - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - if float(gateway.protocol_version) < 1.5: - continue - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_HVAC: [set_req.V_HVAC_FLOW_STATE], - } - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, MySensorsHVAC, add_devices)) - - -class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice): + """Setup the mysensors climate.""" + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsHVAC, add_devices=add_devices) + + +class MySensorsHVAC(mysensors.MySensorsEntity, ClimateDevice): """Representation of a MySensors HVAC.""" @property @@ -84,26 +64,28 @@ def target_temperature(self): temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL) if temp is None: temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) - return float(temp) + return float(temp) if temp is not None else None @property def target_temperature_high(self): """Return the highbound target temperature we try to reach.""" set_req = self.gateway.const.SetReq if set_req.V_HVAC_SETPOINT_HEAT in self._values: - return float(self._values.get(set_req.V_HVAC_SETPOINT_COOL)) + temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL) + return float(temp) if temp is not None else None @property def target_temperature_low(self): """Return the lowbound target temperature we try to reach.""" set_req = self.gateway.const.SetReq if set_req.V_HVAC_SETPOINT_COOL in self._values: - return float(self._values.get(set_req.V_HVAC_SETPOINT_HEAT)) + temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) + return float(temp) if temp is not None else None @property def current_operation(self): """Return current operation ie. heat, cool, idle.""" - return self._values.get(self.gateway.const.SetReq.V_HVAC_FLOW_STATE) + return self._values.get(self.value_type) @property def operation_list(self): @@ -128,7 +110,7 @@ def set_temperature(self, **kwargs): high = kwargs.get(ATTR_TARGET_TEMP_HIGH) heat = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) cool = self._values.get(set_req.V_HVAC_SETPOINT_COOL) - updates = () + updates = [] if temp is not None: if heat is not None: # Set HEAT Target temperature @@ -146,7 +128,7 @@ def set_temperature(self, **kwargs): self.gateway.set_child_value( self.node_id, self.child_id, value_type, value) if self.gateway.optimistic: - # optimistically assume that switch has changed state + # optimistically assume that device has changed state self._values[value_type] = value self.schedule_update_ha_state() @@ -156,54 +138,22 @@ def set_fan_mode(self, fan): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan) if self.gateway.optimistic: - # optimistically assume that switch has changed state + # optimistically assume that device has changed state self._values[set_req.V_HVAC_SPEED] = fan self.schedule_update_ha_state() def set_operation_mode(self, operation_mode): """Set new target temperature.""" - set_req = self.gateway.const.SetReq self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_HVAC_FLOW_STATE, + self.node_id, self.child_id, self.value_type, DICT_HA_TO_MYS[operation_mode]) if self.gateway.optimistic: - # optimistically assume that switch has changed state - self._values[set_req.V_HVAC_FLOW_STATE] = operation_mode + # optimistically assume that device has changed state + self._values[self.value_type] = operation_mode self.schedule_update_ha_state() def update(self): """Update the controller with the latest value from a sensor.""" - set_req = self.gateway.const.SetReq - node = self.gateway.sensors[self.node_id] - child = node.children[self.child_id] - for value_type, value in child.values.items(): - _LOGGER.debug( - "%s: value_type %s, value = %s", self._name, value_type, value) - if value_type == set_req.V_HVAC_FLOW_STATE: - self._values[value_type] = DICT_MYS_TO_HA[value] - else: - self._values[value_type] = value - - def set_humidity(self, humidity): - """Set new target humidity.""" - _LOGGER.error("Service Not Implemented yet") - - def set_swing_mode(self, swing_mode): - """Set new target swing operation.""" - _LOGGER.error("Service Not Implemented yet") - - def turn_away_mode_on(self): - """Turn away mode on.""" - _LOGGER.error("Service Not Implemented yet") - - def turn_away_mode_off(self): - """Turn away mode off.""" - _LOGGER.error("Service Not Implemented yet") - - def turn_aux_heat_on(self): - """Turn auxillary heater on.""" - _LOGGER.error("Service Not Implemented yet") - - def turn_aux_heat_off(self): - """Turn auxillary heater off.""" - _LOGGER.error("Service Not Implemented yet") + super().update() + self._values[self.value_type] = DICT_MYS_TO_HA[ + self._values[self.value_type]] diff --git a/homeassistant/components/climate/tesla.py b/homeassistant/components/climate/tesla.py new file mode 100644 index 0000000000000..39d002e72d9b1 --- /dev/null +++ b/homeassistant/components/climate/tesla.py @@ -0,0 +1,93 @@ +""" +Support for Tesla HVAC system. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/climate.tesla/ +""" +import logging + +from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.components.climate import ClimateDevice, ENTITY_ID_FORMAT +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.const import ( + TEMP_FAHRENHEIT, TEMP_CELSIUS, ATTR_TEMPERATURE) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + +OPERATION_LIST = [STATE_ON, STATE_OFF] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla climate platform.""" + devices = [TeslaThermostat(device, hass.data[TESLA_DOMAIN]['controller']) + for device in hass.data[TESLA_DOMAIN]['devices']['climate']] + add_devices(devices, True) + + +class TeslaThermostat(TeslaDevice, ClimateDevice): + """Representation of a Tesla climate.""" + + def __init__(self, tesla_device, controller): + """Initialize the Tesla device.""" + super().__init__(tesla_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + self._target_temperature = None + self._temperature = None + self._name = self.tesla_device.name + + @property + def current_operation(self): + """Return current operation ie. On or Off.""" + mode = self.tesla_device.is_hvac_enabled() + if mode: + return OPERATION_LIST[0] # On + else: + return OPERATION_LIST[1] # Off + + @property + def operation_list(self): + """List of available operation modes.""" + return OPERATION_LIST + + def update(self): + """Called by the Tesla device callback to update state.""" + _LOGGER.debug("Updating: %s", self._name) + self.tesla_device.update() + self._target_temperature = self.tesla_device.get_goal_temp() + self._temperature = self.tesla_device.get_current_temp() + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + tesla_temp_units = self.tesla_device.measurement + + if tesla_temp_units == 'F': + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + _LOGGER.debug("Setting temperature for: %s", self._name) + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature: + self.tesla_device.set_temperature(temperature) + + def set_operation_mode(self, operation_mode): + """Set HVAC mode (auto, cool, heat, off).""" + _LOGGER.debug("Setting mode for: %s", self._name) + if operation_mode == OPERATION_LIST[1]: # off + self.tesla_device.set_status(False) + elif operation_mode == OPERATION_LIST[0]: # heat + self.tesla_device.set_status(True) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py new file mode 100644 index 0000000000000..8804f6d113fab --- /dev/null +++ b/homeassistant/components/cloud/__init__.py @@ -0,0 +1,49 @@ +"""Component to integrate the Home Assistant cloud.""" +import asyncio +import logging + +import voluptuous as vol + +from . import http_api, cloud_api +from .const import DOMAIN + + +DEPENDENCIES = ['http'] +CONF_MODE = 'mode' +MODE_DEV = 'development' +MODE_STAGING = 'staging' +MODE_PRODUCTION = 'production' +DEFAULT_MODE = MODE_DEV + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_MODE, default=DEFAULT_MODE): + vol.In([MODE_DEV, MODE_STAGING, MODE_PRODUCTION]), + }), +}, extra=vol.ALLOW_EXTRA) +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the Home Assistant cloud.""" + mode = MODE_PRODUCTION + + if DOMAIN in config: + mode = config[DOMAIN].get(CONF_MODE) + + if mode != 'development': + _LOGGER.error('Only development mode is currently allowed.') + return False + + data = hass.data[DOMAIN] = { + 'mode': mode + } + + cloud = yield from cloud_api.async_load_auth(hass) + + if cloud is not None: + data['cloud'] = cloud + + yield from http_api.async_setup(hass) + return True diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py new file mode 100644 index 0000000000000..6429da145167d --- /dev/null +++ b/homeassistant/components/cloud/cloud_api.py @@ -0,0 +1,297 @@ +"""Package to offer tools to communicate with the cloud.""" +import asyncio +from datetime import timedelta +import json +import logging +import os +from urllib.parse import urljoin + +import aiohttp +import async_timeout + +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.dt import utcnow + +from .const import AUTH_FILE, REQUEST_TIMEOUT, SERVERS +from .util import get_mode + +_LOGGER = logging.getLogger(__name__) + + +URL_CREATE_TOKEN = 'o/token/' +URL_REVOKE_TOKEN = 'o/revoke_token/' +URL_ACCOUNT = 'account.json' + + +class CloudError(Exception): + """Base class for cloud related errors.""" + + def __init__(self, reason=None, status=None): + """Initialize a cloud error.""" + super().__init__(reason) + self.status = status + + +class Unauthenticated(CloudError): + """Raised when authentication failed.""" + + +class UnknownError(CloudError): + """Raised when an unknown error occurred.""" + + +@asyncio.coroutine +def async_load_auth(hass): + """Load authentication from disk and verify it.""" + auth = yield from hass.async_add_job(_read_auth, hass) + + if not auth: + return None + + cloud = Cloud(hass, auth) + + try: + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + auth_check = yield from cloud.async_refresh_account_info() + + if not auth_check: + _LOGGER.error('Unable to validate credentials.') + return None + + return cloud + + except asyncio.TimeoutError: + _LOGGER.error('Unable to reach server to validate credentials.') + return None + + +@asyncio.coroutine +def async_login(hass, username, password, scope=None): + """Get a token using a username and password. + + Returns a coroutine. + """ + data = { + 'grant_type': 'password', + 'username': username, + 'password': password + } + if scope is not None: + data['scope'] = scope + + auth = yield from _async_get_token(hass, data) + + yield from hass.async_add_job(_write_auth, hass, auth) + + return Cloud(hass, auth) + + +@asyncio.coroutine +def _async_get_token(hass, data): + """Get a new token and return it as a dictionary. + + Raises exceptions when errors occur: + - Unauthenticated + - UnknownError + """ + session = async_get_clientsession(hass) + auth = aiohttp.BasicAuth(*_client_credentials(hass)) + + try: + req = yield from session.post( + _url(hass, URL_CREATE_TOKEN), + data=data, + auth=auth + ) + + if req.status == 401: + _LOGGER.error('Cloud login failed: %d', req.status) + raise Unauthenticated(status=req.status) + elif req.status != 200: + _LOGGER.error('Cloud login failed: %d', req.status) + raise UnknownError(status=req.status) + + response = yield from req.json() + response['expires_at'] = \ + (utcnow() + timedelta(seconds=response['expires_in'])).isoformat() + + return response + + except aiohttp.ClientError: + raise UnknownError() + + +class Cloud: + """Store Hass Cloud info.""" + + def __init__(self, hass, auth): + """Initialize Hass cloud info object.""" + self.hass = hass + self.auth = auth + self.account = None + + @property + def access_token(self): + """Return access token.""" + return self.auth['access_token'] + + @property + def refresh_token(self): + """Get refresh token.""" + return self.auth['refresh_token'] + + @asyncio.coroutine + def async_refresh_account_info(self): + """Refresh the account info.""" + req = yield from self.async_request('get', URL_ACCOUNT) + + if req.status != 200: + return False + + self.account = yield from req.json() + return True + + @asyncio.coroutine + def async_refresh_access_token(self): + """Get a token using a refresh token.""" + try: + self.auth = yield from _async_get_token(self.hass, { + 'grant_type': 'refresh_token', + 'refresh_token': self.refresh_token, + }) + + yield from self.hass.async_add_job( + _write_auth, self.hass, self.auth) + + return True + except CloudError: + return False + + @asyncio.coroutine + def async_revoke_access_token(self): + """Revoke active access token.""" + session = async_get_clientsession(self.hass) + client_id, client_secret = _client_credentials(self.hass) + data = { + 'token': self.access_token, + 'client_id': client_id, + 'client_secret': client_secret + } + try: + req = yield from session.post( + _url(self.hass, URL_REVOKE_TOKEN), + data=data, + ) + + if req.status != 200: + _LOGGER.error('Cloud logout failed: %d', req.status) + raise UnknownError(status=req.status) + + self.auth = None + yield from self.hass.async_add_job( + _write_auth, self.hass, None) + + except aiohttp.ClientError: + raise UnknownError() + + @asyncio.coroutine + def async_request(self, method, path, **kwargs): + """Make a request to Home Assistant cloud. + + Will refresh the token if necessary. + """ + session = async_get_clientsession(self.hass) + url = _url(self.hass, path) + + if 'headers' not in kwargs: + kwargs['headers'] = {} + + kwargs['headers']['authorization'] = \ + 'Bearer {}'.format(self.access_token) + + request = yield from session.request(method, url, **kwargs) + + if request.status != 403: + return request + + # Maybe token expired. Try refreshing it. + reauth = yield from self.async_refresh_access_token() + + if not reauth: + return request + + # Release old connection back to the pool. + yield from request.release() + + kwargs['headers']['authorization'] = \ + 'Bearer {}'.format(self.access_token) + + # If we are not already fetching the account info, + # refresh the account info. + + if path != URL_ACCOUNT: + yield from self.async_refresh_account_info() + + request = yield from session.request(method, url, **kwargs) + + return request + + +def _read_auth(hass): + """Read auth file.""" + path = hass.config.path(AUTH_FILE) + + if not os.path.isfile(path): + return None + + with open(path) as file: + return json.load(file).get(get_mode(hass)) + + +def _write_auth(hass, data): + """Write auth info for specified mode. + + Pass in None for data to remove authentication for that mode. + """ + path = hass.config.path(AUTH_FILE) + mode = get_mode(hass) + + if os.path.isfile(path): + with open(path) as file: + content = json.load(file) + else: + content = {} + + if data is None: + content.pop(mode, None) + else: + content[mode] = data + + with open(path, 'wt') as file: + file.write(json.dumps(content, indent=4, sort_keys=True)) + + +def _client_credentials(hass): + """Get the client credentials. + + Async friendly. + """ + mode = get_mode(hass) + + if mode not in SERVERS: + raise ValueError('Mode {} is not supported.'.format(mode)) + + return SERVERS[mode]['client_id'], SERVERS[mode]['client_secret'] + + +def _url(hass, path): + """Generate a url for the cloud. + + Async friendly. + """ + mode = get_mode(hass) + + if mode not in SERVERS: + raise ValueError('Mode {} is not supported.'.format(mode)) + + return urljoin(SERVERS[mode]['host'], path) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py new file mode 100644 index 0000000000000..f55a4be21a2ff --- /dev/null +++ b/homeassistant/components/cloud/const.py @@ -0,0 +1,14 @@ +"""Constants for the cloud component.""" +DOMAIN = 'cloud' +REQUEST_TIMEOUT = 10 +AUTH_FILE = '.cloud' + +SERVERS = { + 'development': { + 'host': 'http://localhost:8000', + 'client_id': 'HBhQxeV8H4aFBcs7jrZUeeDud0FjGEJJSZ9G6gNu', + 'client_secret': ('V1qw2NhB32cSAlP7DOezjgWNgn7ZKgq0jvVZoYSI0KCmg9rg7q4' + 'BSzoebnQnX6tuHCJiZjm2479mZmmtf2LOUdnSqOqkSpjc3js7Wu' + 'VBJrRyfgTVd43kbrEQtuOiaUpK') + } +} diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py new file mode 100644 index 0000000000000..661cc8a7ba1d7 --- /dev/null +++ b/homeassistant/components/cloud/http_api.py @@ -0,0 +1,119 @@ +"""The HTTP api to control the cloud integration.""" +import asyncio +import logging + +import voluptuous as vol +import async_timeout + +from homeassistant.components.http import HomeAssistantView + +from . import cloud_api +from .const import DOMAIN, REQUEST_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup(hass): + """Initialize the HTTP api.""" + hass.http.register_view(CloudLoginView) + hass.http.register_view(CloudLogoutView) + hass.http.register_view(CloudAccountView) + + +class CloudLoginView(HomeAssistantView): + """Login to Home Assistant cloud.""" + + url = '/api/cloud/login' + name = 'api:cloud:login' + schema = vol.Schema({ + vol.Required('username'): str, + vol.Required('password'): str, + }) + + @asyncio.coroutine + def post(self, request): + """Validate config and return results.""" + try: + data = yield from request.json() + except ValueError: + _LOGGER.error('Login with invalid JSON') + return self.json_message('Invalid JSON.', 400) + + try: + self.schema(data) + except vol.Invalid as err: + _LOGGER.error('Login with invalid formatted data') + return self.json_message( + 'Message format incorrect: {}'.format(err), 400) + + hass = request.app['hass'] + phase = 1 + try: + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + cloud = yield from cloud_api.async_login( + hass, data['username'], data['password']) + + phase += 1 + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from cloud.async_refresh_account_info() + + except cloud_api.Unauthenticated: + return self.json_message( + 'Authentication failed (phase {}).'.format(phase), 401) + except cloud_api.UnknownError: + return self.json_message( + 'Unknown error occurred (phase {}).'.format(phase), 500) + except asyncio.TimeoutError: + return self.json_message( + 'Unable to reach Home Assistant cloud ' + '(phase {}).'.format(phase), 502) + + hass.data[DOMAIN]['cloud'] = cloud + return self.json(cloud.account) + + +class CloudLogoutView(HomeAssistantView): + """Log out of the Home Assistant cloud.""" + + url = '/api/cloud/logout' + name = 'api:cloud:logout' + + @asyncio.coroutine + def post(self, request): + """Validate config and return results.""" + hass = request.app['hass'] + try: + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from \ + hass.data[DOMAIN]['cloud'].async_revoke_access_token() + + hass.data[DOMAIN].pop('cloud') + + return self.json({ + 'result': 'ok', + }) + except asyncio.TimeoutError: + return self.json_message("Could not reach the server.", 502) + except cloud_api.UnknownError as err: + return self.json_message( + "Error communicating with the server ({}).".format(err.status), + 502) + + +class CloudAccountView(HomeAssistantView): + """Log out of the Home Assistant cloud.""" + + url = '/api/cloud/account' + name = 'api:cloud:account' + + @asyncio.coroutine + def get(self, request): + """Validate config and return results.""" + hass = request.app['hass'] + + if 'cloud' not in hass.data[DOMAIN]: + return self.json_message('Not logged in', 400) + + return self.json(hass.data[DOMAIN]['cloud'].account) diff --git a/homeassistant/components/cloud/util.py b/homeassistant/components/cloud/util.py new file mode 100644 index 0000000000000..ec5445f0638c0 --- /dev/null +++ b/homeassistant/components/cloud/util.py @@ -0,0 +1,10 @@ +"""Utilities for the cloud integration.""" +from .const import DOMAIN + + +def get_mode(hass): + """Return the current mode of the cloud component. + + Async friendly. + """ + return hass.data[DOMAIN]['mode'] diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 0bc44501e280c..9ce7f30529beb 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -14,7 +14,7 @@ DOMAIN = 'config' DEPENDENCIES = ['http'] -SECTIONS = ('core', 'group', 'hassbian', 'automation') +SECTIONS = ('core', 'customize', 'group', 'hassbian', 'automation', 'script') ON_DEMAND = ('zwave') @@ -77,11 +77,11 @@ def _empty_config(self): """Empty config if file not found.""" raise NotImplementedError - def _get_value(self, data, config_key): + def _get_value(self, hass, data, config_key): """Get value.""" raise NotImplementedError - def _write_value(self, data, config_key, new_value): + def _write_value(self, hass, data, config_key, new_value): """Set value.""" raise NotImplementedError @@ -90,7 +90,7 @@ def get(self, request, config_key): """Fetch device specific config.""" hass = request.app['hass'] current = yield from self.read_config(hass) - value = self._get_value(current, config_key) + value = self._get_value(hass, current, config_key) if value is None: return self.json_message('Resource not found', 404) @@ -121,7 +121,7 @@ def post(self, request, config_key): path = hass.config.path(self.path) current = yield from self.read_config(hass) - self._write_value(current, config_key, data) + self._write_value(hass, current, config_key, data) yield from hass.async_add_job(_write, path, current) @@ -149,11 +149,11 @@ def _empty_config(self): """Return an empty config.""" return {} - def _get_value(self, data, config_key): + def _get_value(self, hass, data, config_key): """Get value.""" return data.get(config_key, {}) - def _write_value(self, data, config_key, new_value): + def _write_value(self, hass, data, config_key, new_value): """Set value.""" data.setdefault(config_key, {}).update(new_value) @@ -165,14 +165,14 @@ def _empty_config(self): """Return an empty config.""" return [] - def _get_value(self, data, config_key): + def _get_value(self, hass, data, config_key): """Get value.""" return next( (val for val in data if val.get(CONF_ID) == config_key), None) - def _write_value(self, data, config_key, new_value): + def _write_value(self, hass, data, config_key, new_value): """Set value.""" - value = self._get_value(data, config_key) + value = self._get_value(hass, data, config_key) if value is None: value = {CONF_ID: config_key} diff --git a/homeassistant/components/config/customize.py b/homeassistant/components/config/customize.py new file mode 100644 index 0000000000000..d25992ecc907c --- /dev/null +++ b/homeassistant/components/config/customize.py @@ -0,0 +1,39 @@ +"""Provide configuration end points for Customize.""" +import asyncio + +from homeassistant.components.config import EditKeyBasedConfigView +from homeassistant.components import async_reload_core_config +from homeassistant.config import DATA_CUSTOMIZE + +import homeassistant.helpers.config_validation as cv + +CONFIG_PATH = 'customize.yaml' + + +@asyncio.coroutine +def async_setup(hass): + """Set up the Customize config API.""" + hass.http.register_view(CustomizeConfigView( + 'customize', 'config', CONFIG_PATH, cv.entity_id, dict, + post_write_hook=async_reload_core_config + )) + + return True + + +class CustomizeConfigView(EditKeyBasedConfigView): + """Configure a list of entries.""" + + def _get_value(self, hass, data, config_key): + """Get value.""" + customize = hass.data.get(DATA_CUSTOMIZE, {}).get(config_key) or {} + return {'global': customize, 'local': data.get(config_key, {})} + + def _write_value(self, hass, data, config_key, new_value): + """Set value.""" + data[config_key] = new_value + + state = hass.states.get(config_key) + state_attributes = dict(state.attributes) + state_attributes.update(new_value) + hass.states.async_set(config_key, state.state, state_attributes) diff --git a/homeassistant/components/config/script.py b/homeassistant/components/config/script.py new file mode 100644 index 0000000000000..345c8e4a849f4 --- /dev/null +++ b/homeassistant/components/config/script.py @@ -0,0 +1,19 @@ +"""Provide configuration end points for scripts.""" +import asyncio + +from homeassistant.components.config import EditKeyBasedConfigView +from homeassistant.components.script import SCRIPT_ENTRY_SCHEMA, async_reload +import homeassistant.helpers.config_validation as cv + + +CONFIG_PATH = 'scripts.yaml' + + +@asyncio.coroutine +def async_setup(hass): + """Set up the script config API.""" + hass.http.register_view(EditKeyBasedConfigView( + 'script', 'config', CONFIG_PATH, cv.slug, SCRIPT_ENTRY_SCHEMA, + post_write_hook=async_reload + )) + return True diff --git a/homeassistant/components/configurator.py b/homeassistant/components/configurator.py index 660a62a5b895e..2da8967bddf5a 100644 --- a/homeassistant/components/configurator.py +++ b/homeassistant/components/configurator.py @@ -7,19 +7,21 @@ the user has submitted configuration information. """ import asyncio +import functools as ft import logging from homeassistant.core import callback as async_callback from homeassistant.const import EVENT_TIME_CHANGED, ATTR_FRIENDLY_NAME, \ ATTR_ENTITY_PICTURE from homeassistant.loader import bind_hass -from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.util.async import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) -_REQUESTS = {} _KEY_INSTANCE = 'configurator' +DATA_REQUESTS = 'configurator_requests' + ATTR_CONFIGURE_ID = 'configure_id' ATTR_DESCRIPTION = 'description' ATTR_DESCRIPTION_IMAGE = 'description_image' @@ -39,63 +41,89 @@ @bind_hass -def request_config( - hass, name, callback, description=None, description_image=None, +@async_callback +def async_request_config( + hass, name, callback=None, description=None, description_image=None, submit_caption=None, fields=None, link_name=None, link_url=None, entity_picture=None): """Create a new request for configuration. Will return an ID to be used for sequent calls. """ - instance = run_callback_threadsafe(hass.loop, - _async_get_instance, - hass).result() + instance = hass.data.get(_KEY_INSTANCE) - request_id = instance.request_config( + if instance is None: + instance = hass.data[_KEY_INSTANCE] = Configurator(hass) + + request_id = instance.async_request_config( name, callback, description, description_image, submit_caption, fields, link_name, link_url, entity_picture) - _REQUESTS[request_id] = instance + if DATA_REQUESTS not in hass.data: + hass.data[DATA_REQUESTS] = {} + + hass.data[DATA_REQUESTS][request_id] = instance return request_id -def notify_errors(request_id, error): +@bind_hass +def request_config(hass, *args, **kwargs): + """Create a new request for configuration. + + Will return an ID to be used for sequent calls. + """ + return run_callback_threadsafe( + hass.loop, ft.partial(async_request_config, hass, *args, **kwargs) + ).result() + + +@bind_hass +@async_callback +def async_notify_errors(hass, request_id, error): """Add errors to a config request.""" try: - _REQUESTS[request_id].notify_errors(request_id, error) + hass.data[DATA_REQUESTS][request_id].async_notify_errors( + request_id, error) except KeyError: # If request_id does not exist pass -def request_done(request_id): +@bind_hass +def notify_errors(hass, request_id, error): + """Add errors to a config request.""" + return run_callback_threadsafe( + hass.loop, async_notify_errors, hass, request_id, error + ).result() + + +@bind_hass +@async_callback +def async_request_done(hass, request_id): """Mark a configuration request as done.""" try: - _REQUESTS.pop(request_id).request_done(request_id) + hass.data[DATA_REQUESTS].pop(request_id).async_request_done(request_id) except KeyError: # If request_id does not exist pass +@bind_hass +def request_done(hass, request_id): + """Mark a configuration request as done.""" + return run_callback_threadsafe( + hass.loop, async_request_done, hass, request_id + ).result() + + @asyncio.coroutine def async_setup(hass, config): """Set up the configurator component.""" return True -@async_callback -def _async_get_instance(hass): - """Get an instance per hass object.""" - instance = hass.data.get(_KEY_INSTANCE) - - if instance is None: - instance = hass.data[_KEY_INSTANCE] = Configurator(hass) - - return instance - - class Configurator(object): """The class to keep track of current configuration requests.""" @@ -105,14 +133,16 @@ def __init__(self, hass): self._cur_id = 0 self._requests = {} hass.services.async_register( - DOMAIN, SERVICE_CONFIGURE, self.handle_service_call) + DOMAIN, SERVICE_CONFIGURE, self.async_handle_service_call) - def request_config( + @async_callback + def async_request_config( self, name, callback, description, description_image, submit_caption, fields, link_name, link_url, entity_picture): """Set up a request for configuration.""" - entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, hass=self.hass) + entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, name, hass=self.hass) if fields is None: fields = [] @@ -138,11 +168,12 @@ def request_config( ] if value is not None }) - self.hass.states.set(entity_id, STATE_CONFIGURE, data) + self.hass.states.async_set(entity_id, STATE_CONFIGURE, data) return request_id - def notify_errors(self, request_id, error): + @async_callback + def async_notify_errors(self, request_id, error): """Update the state with errors.""" if not self._validate_request_id(request_id): return @@ -154,9 +185,10 @@ def notify_errors(self, request_id, error): new_data = dict(state.attributes) new_data[ATTR_ERRORS] = error - self.hass.states.set(entity_id, STATE_CONFIGURE, new_data) + self.hass.states.async_set(entity_id, STATE_CONFIGURE, new_data) - def request_done(self, request_id): + @async_callback + def async_request_done(self, request_id): """Remove the configuration request.""" if not self._validate_request_id(request_id): return @@ -167,15 +199,16 @@ def request_done(self, request_id): # the result fo the service call (current design limitation). # Instead, we will set it to configured to give as feedback but delete # it shortly after so that it is deleted when the client updates. - self.hass.states.set(entity_id, STATE_CONFIGURED) + self.hass.states.async_set(entity_id, STATE_CONFIGURED) def deferred_remove(event): """Remove the request state.""" - self.hass.states.remove(entity_id) + self.hass.states.async_remove(entity_id) - self.hass.bus.listen_once(EVENT_TIME_CHANGED, deferred_remove) + self.hass.bus.async_listen_once(EVENT_TIME_CHANGED, deferred_remove) - def handle_service_call(self, call): + @async_callback + def async_handle_service_call(self, call): """Handle a configure service call.""" request_id = call.data.get(ATTR_CONFIGURE_ID) @@ -186,8 +219,8 @@ def handle_service_call(self, call): entity_id, fields, callback = self._requests[request_id] # field validation goes here? - - self.hass.async_add_job(callback, call.data.get(ATTR_FIELDS, {})) + if callback: + self.hass.async_add_job(callback, call.data.get(ATTR_FIELDS, {})) def _generate_unique_id(self): """Generate a unique configurator ID.""" diff --git a/homeassistant/components/counter.py b/homeassistant/components/counter.py new file mode 100644 index 0000000000000..64421306644f7 --- /dev/null +++ b/homeassistant/components/counter.py @@ -0,0 +1,220 @@ +""" +Component to count within automations. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/counter/ +""" +import asyncio +import logging +import os + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (ATTR_ENTITY_ID, CONF_ICON, CONF_NAME) +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.loader import bind_hass + +_LOGGER = logging.getLogger(__name__) + +ATTR_INITIAL = 'initial' +ATTR_STEP = 'step' + +CONF_INITIAL = 'initial' +CONF_STEP = 'step' + +DEFAULT_INITIAL = 0 +DEFAULT_STEP = 1 +DOMAIN = 'counter' + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +SERVICE_DECREMENT = 'decrement' +SERVICE_INCREMENT = 'increment' +SERVICE_RESET = 'reset' + +SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: vol.Any({ + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_INITIAL, default=DEFAULT_INITIAL): + cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_STEP, default=DEFAULT_STEP): cv.positive_int, + }, None) + }) +}, extra=vol.ALLOW_EXTRA) + + +@bind_hass +def increment(hass, entity_id): + """Increment a counter.""" + hass.add_job(async_increment, hass, entity_id) + + +@callback +@bind_hass +def async_increment(hass, entity_id): + """Increment a counter.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_INCREMENT, {ATTR_ENTITY_ID: entity_id})) + + +@bind_hass +def decrement(hass, entity_id): + """Decrement a counter.""" + hass.add_job(async_decrement, hass, entity_id) + + +@callback +@bind_hass +def async_decrement(hass, entity_id): + """Decrement a counter.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_DECREMENT, {ATTR_ENTITY_ID: entity_id})) + + +@bind_hass +def reset(hass, entity_id): + """Reset a counter.""" + hass.add_job(async_reset, hass, entity_id) + + +@callback +@bind_hass +def async_reset(hass, entity_id): + """Reset a counter.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_RESET, {ATTR_ENTITY_ID: entity_id})) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up a counter.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + if not cfg: + cfg = {} + + name = cfg.get(CONF_NAME) + initial = cfg.get(CONF_INITIAL) + step = cfg.get(CONF_STEP) + icon = cfg.get(CONF_ICON) + + entities.append(Counter(object_id, name, initial, step, icon)) + + if not entities: + return False + + @asyncio.coroutine + def async_handler_service(service): + """Handle a call to the counter services.""" + target_counters = component.async_extract_from_service(service) + + if service.service == SERVICE_INCREMENT: + attr = 'async_increment' + elif service.service == SERVICE_DECREMENT: + attr = 'async_decrement' + elif service.service == SERVICE_RESET: + attr = 'async_reset' + + tasks = [getattr(counter, attr)() for counter in target_counters] + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml') + ) + + hass.services.async_register( + DOMAIN, SERVICE_INCREMENT, async_handler_service, + descriptions[DOMAIN][SERVICE_INCREMENT], SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_DECREMENT, async_handler_service, + descriptions[DOMAIN][SERVICE_DECREMENT], SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_RESET, async_handler_service, + descriptions[DOMAIN][SERVICE_RESET], SERVICE_SCHEMA) + + yield from component.async_add_entities(entities) + return True + + +class Counter(Entity): + """Representation of a counter.""" + + def __init__(self, object_id, name, initial, step, icon): + """Initialize a counter.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._step = step + self._state = self._initial = initial + self._icon = icon + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return name of the counter.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the current value of the counter.""" + return self._state + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_INITIAL: self._initial, + ATTR_STEP: self._step, + } + + @asyncio.coroutine + def async_added_to_hass(self): + """Call when entity about to be added to Home Assistant.""" + # If not None, we got an initial value. + if self._state is not None: + return + + state = yield from async_get_last_state(self.hass, self.entity_id) + self._state = state and state.state == state + + @asyncio.coroutine + def async_decrement(self): + """Decrement the counter.""" + self._state -= self._step + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_increment(self): + """Increment a counter.""" + self._state += self._step + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_reset(self): + """Reset a counter.""" + self._state = self._initial + yield from self.async_update_ha_state() diff --git a/homeassistant/components/cover/abode.py b/homeassistant/components/cover/abode.py new file mode 100644 index 0000000000000..b09c9e5e00762 --- /dev/null +++ b/homeassistant/components/cover/abode.py @@ -0,0 +1,49 @@ +""" +This component provides HA cover support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.cover import CoverDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode cover devices.""" + import abodepy.helpers.constants as CONST + + abode = hass.data[DATA_ABODE] + + sensors = [] + for sensor in abode.get_devices(type_filter=(CONST.DEVICE_SECURE_BARRIER)): + sensors.append(AbodeCover(abode, sensor)) + + add_devices(sensors) + + +class AbodeCover(AbodeDevice, CoverDevice): + """Representation of an Abode cover.""" + + def __init__(self, controller, device): + """Initialize the Abode device.""" + AbodeDevice.__init__(self, controller, device) + + @property + def is_closed(self): + """Return true if cover is closed, else False.""" + return self._device.is_open is False + + def close_cover(self): + """Issue close command to cover.""" + self._device.close_cover() + + def open_cover(self): + """Issue open command to cover.""" + self._device.open_cover() diff --git a/homeassistant/components/cover/homematic.py b/homeassistant/components/cover/homematic.py index e8372b84ce488..9e3d675cabebe 100644 --- a/homeassistant/components/cover/homematic.py +++ b/homeassistant/components/cover/homematic.py @@ -21,8 +21,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMCover(hass, conf) - new_device.link_homematic() + new_device = HMCover(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index 4883cfe3648f9..e4c2931983d51 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -1,185 +1,239 @@ """ -Support for KNX covers. +Support for KNX/IP covers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.knx/ """ -import logging - +import asyncio import voluptuous as vol +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.helpers.event import async_track_utc_time_change from homeassistant.components.cover import ( - CoverDevice, PLATFORM_SCHEMA, ATTR_POSITION, DEVICE_CLASSES_SCHEMA, - SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION, SUPPORT_STOP, - SUPPORT_SET_TILT_POSITION -) -from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice) -from homeassistant.const import (CONF_NAME, CONF_DEVICE_CLASS) + CoverDevice, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE, + SUPPORT_SET_POSITION, SUPPORT_STOP, SUPPORT_SET_TILT_POSITION, + ATTR_POSITION, ATTR_TILT_POSITION) +from homeassistant.core import callback +from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) - -CONF_GETPOSITION_ADDRESS = 'getposition_address' -CONF_SETPOSITION_ADDRESS = 'setposition_address' -CONF_GETANGLE_ADDRESS = 'getangle_address' -CONF_SETANGLE_ADDRESS = 'setangle_address' -CONF_STOP = 'stop_address' -CONF_UPDOWN = 'updown_address' +CONF_MOVE_LONG_ADDRESS = 'move_long_address' +CONF_MOVE_SHORT_ADDRESS = 'move_short_address' +CONF_POSITION_ADDRESS = 'position_address' +CONF_POSITION_STATE_ADDRESS = 'position_state_address' +CONF_ANGLE_ADDRESS = 'angle_address' +CONF_ANGLE_STATE_ADDRESS = 'angle_state_address' +CONF_TRAVELLING_TIME_DOWN = 'travelling_time_down' +CONF_TRAVELLING_TIME_UP = 'travelling_time_up' CONF_INVERT_POSITION = 'invert_position' CONF_INVERT_ANGLE = 'invert_angle' +DEFAULT_TRAVEL_TIME = 25 DEFAULT_NAME = 'KNX Cover' DEPENDENCIES = ['knx'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_UPDOWN): cv.string, - vol.Required(CONF_STOP): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_GETPOSITION_ADDRESS): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SETPOSITION_ADDRESS): cv.string, + vol.Optional(CONF_MOVE_LONG_ADDRESS): cv.string, + vol.Optional(CONF_MOVE_SHORT_ADDRESS): cv.string, + vol.Optional(CONF_POSITION_ADDRESS): cv.string, + vol.Optional(CONF_POSITION_STATE_ADDRESS): cv.string, + vol.Optional(CONF_ANGLE_ADDRESS): cv.string, + vol.Optional(CONF_ANGLE_STATE_ADDRESS): cv.string, + vol.Optional(CONF_TRAVELLING_TIME_DOWN, default=DEFAULT_TRAVEL_TIME): + cv.positive_int, + vol.Optional(CONF_TRAVELLING_TIME_UP, default=DEFAULT_TRAVEL_TIME): + cv.positive_int, vol.Optional(CONF_INVERT_POSITION, default=False): cv.boolean, - vol.Inclusive(CONF_GETANGLE_ADDRESS, 'angle'): cv.string, - vol.Inclusive(CONF_SETANGLE_ADDRESS, 'angle'): cv.string, vol.Optional(CONF_INVERT_ANGLE, default=False): cv.boolean, }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Create and add an entity based on the configuration.""" - add_devices([KNXCover(hass, KNXConfig(config))]) - - -class KNXCover(KNXMultiAddressDevice, CoverDevice): - """Representation of a KNX cover. e.g. a rollershutter.""" - - def __init__(self, hass, config): +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up cover(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True + + +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up covers for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXCover(hass, device)) + add_devices(entities) + + +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up cover for KNX platform configured within plattform.""" + import xknx + cover = xknx.devices.Cover( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address_long=config.get(CONF_MOVE_LONG_ADDRESS), + group_address_short=config.get(CONF_MOVE_SHORT_ADDRESS), + group_address_position_state=config.get( + CONF_POSITION_STATE_ADDRESS), + group_address_angle=config.get(CONF_ANGLE_ADDRESS), + group_address_angle_state=config.get(CONF_ANGLE_STATE_ADDRESS), + group_address_position=config.get(CONF_POSITION_ADDRESS), + travel_time_down=config.get(CONF_TRAVELLING_TIME_DOWN), + travel_time_up=config.get(CONF_TRAVELLING_TIME_UP)) + + invert_position = config.get(CONF_INVERT_POSITION) + invert_angle = config.get(CONF_INVERT_ANGLE) + hass.data[DATA_KNX].xknx.devices.add(cover) + add_devices([KNXCover(hass, cover, invert_position, invert_angle)]) + + +class KNXCover(CoverDevice): + """Representation of a KNX cover.""" + + def __init__(self, hass, device, invert_position=False, + invert_angle=False): """Initialize the cover.""" - KNXMultiAddressDevice.__init__( - self, hass, config, - ['updown', 'stop'], # required - optional=['setposition', 'getposition', - 'getangle', 'setangle'] - ) - self._device_class = config.config.get(CONF_DEVICE_CLASS) - self._invert_position = config.config.get(CONF_INVERT_POSITION) - self._invert_angle = config.config.get(CONF_INVERT_ANGLE) - self._hass = hass - self._current_pos = None - self._target_pos = None - self._current_tilt = None - self._target_tilt = None - self._supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | \ - SUPPORT_SET_POSITION | SUPPORT_STOP + self.device = device + self.invert_position = invert_position + self.invert_angle = invert_angle + self.hass = hass + self.async_register_callbacks() + + self._unsubscribe_auto_updater = None + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) - # Tilt is only supported, if there is a angle get and set address - if CONF_SETANGLE_ADDRESS in config.config: - _LOGGER.debug("%s: Tilt supported at addresses %s, %s", - self.name, config.config.get(CONF_SETANGLE_ADDRESS), - config.config.get(CONF_GETANGLE_ADDRESS)) - self._supported_features = self._supported_features | \ - SUPPORT_SET_TILT_POSITION + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name @property def should_poll(self): - """Polling is needed for the KNX cover.""" - return True + """No polling needed within KNX.""" + return False @property def supported_features(self): """Flag supported features.""" - return self._supported_features - - @property - def is_closed(self): - """Return if the cover is closed.""" - if self.current_cover_position is not None: - if self.current_cover_position > 0: - return False - else: - return True + supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | \ + SUPPORT_SET_POSITION | SUPPORT_STOP + if self.device.supports_angle: + supported_features |= SUPPORT_SET_TILT_POSITION + return supported_features @property def current_cover_position(self): - """Return current position of cover. - - None is unknown, 0 is closed, 100 is fully open. - """ - return self._current_pos + """Return the current position of the cover.""" + return int(self.from_knx_position( + self.device.current_position(), + self.invert_position)) @property - def target_position(self): - """Return the position we are trying to reach: 0 - 100.""" - return self._target_pos - - @property - def current_cover_tilt_position(self): - """Return current position of cover. - - None is unknown, 0 is closed, 100 is fully open. - """ - return self._current_tilt - - @property - def target_tilt(self): - """Return the tilt angle (in %) we are trying to reach: 0 - 100.""" - return self._target_tilt - - def set_cover_position(self, **kwargs): - """Set new target position.""" - position = kwargs.get(ATTR_POSITION) - if position is None: - return - - if self._invert_position: - position = 100-position - - self._target_pos = position - self.set_percentage('setposition', position) - _LOGGER.debug("%s: Set target position to %d", self.name, position) - - def update(self): - """Update device state.""" - super().update() - value = self.get_percentage('getposition') - if value is not None: - self._current_pos = value - if self._invert_position: - self._current_pos = 100-value - _LOGGER.debug("%s: position = %d", self.name, value) - - if self._supported_features & SUPPORT_SET_TILT_POSITION: - value = self.get_percentage('getangle') - if value is not None: - self._current_tilt = value - if self._invert_angle: - self._current_tilt = 100-value - _LOGGER.debug("%s: tilt = %d", self.name, value) - - def open_cover(self, **kwargs): - """Open the cover.""" - _LOGGER.debug("%s: open: updown = 0", self.name) - self.set_int_value('updown', 0) + def is_closed(self): + """Return if the cover is closed.""" + return self.device.is_closed() - def close_cover(self, **kwargs): + @asyncio.coroutine + def async_close_cover(self, **kwargs): """Close the cover.""" - _LOGGER.debug("%s: open: updown = 1", self.name) - self.set_int_value('updown', 1) - - def stop_cover(self, **kwargs): - """Stop the cover movement.""" - _LOGGER.debug("%s: stop: stop = 1", self.name) - self.set_int_value('stop', 1) + if not self.device.is_closed(): + yield from self.device.set_down() + self.start_auto_updater() - def set_cover_tilt_position(self, tilt_position, **kwargs): - """Move the cover til to a specific position.""" - if self._invert_angle: - tilt_position = 100-tilt_position - - self._target_tilt = round(tilt_position, -1) - self.set_percentage('setangle', tilt_position) + @asyncio.coroutine + def async_open_cover(self, **kwargs): + """Open the cover.""" + if not self.device.is_open(): + yield from self.device.set_up() + self.start_auto_updater() + + @asyncio.coroutine + def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + if ATTR_POSITION in kwargs: + position = kwargs[ATTR_POSITION] + knx_position = self.to_knx_position(position, self.invert_position) + yield from self.device.set_position(knx_position) + self.start_auto_updater() + + @asyncio.coroutine + def async_stop_cover(self, **kwargs): + """Stop the cover.""" + yield from self.device.stop() + self.stop_auto_updater() @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return self._device_class + def current_cover_tilt_position(self): + """Return current tilt position of cover.""" + if not self.device.supports_angle: + return None + return int(self.from_knx_position( + self.device.angle, + self.invert_angle)) + + @asyncio.coroutine + def async_set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + if ATTR_TILT_POSITION in kwargs: + position = kwargs[ATTR_TILT_POSITION] + knx_position = self.to_knx_position(position, self.invert_angle) + yield from self.device.set_angle(knx_position) + + def start_auto_updater(self): + """Start the autoupdater to update HASS while cover is moving.""" + if self._unsubscribe_auto_updater is None: + self._unsubscribe_auto_updater = async_track_utc_time_change( + self.hass, self.auto_updater_hook) + + def stop_auto_updater(self): + """Stop the autoupdater.""" + if self._unsubscribe_auto_updater is not None: + self._unsubscribe_auto_updater() + self._unsubscribe_auto_updater = None + + @callback + def auto_updater_hook(self, now): + """Callback for autoupdater.""" + # pylint: disable=unused-argument + self.hass.async_add_job(self.async_update_ha_state()) + if self.device.position_reached(): + self.stop_auto_updater() + + self.hass.add_job(self.device.auto_stop_if_necessary()) + + @staticmethod + def from_knx_position(raw, invert): + """Convert KNX position [0...255] to hass position [100...0].""" + position = round((raw/256)*100) + if not invert: + position = 100 - position + return position + + @staticmethod + def to_knx_position(value, invert): + """Convert hass position [100...0] to KNX position [0...255].""" + knx_position = round(value/100*255.4) + if not invert: + knx_position = 255-knx_position + print(value, " -> ", knx_position) + return knx_position diff --git a/homeassistant/components/cover/lutron_caseta.py b/homeassistant/components/cover/lutron_caseta.py index 648dba98ca6a9..31e4f1e3cf281 100644 --- a/homeassistant/components/cover/lutron_caseta.py +++ b/homeassistant/components/cover/lutron_caseta.py @@ -1,14 +1,14 @@ """ -Support for Lutron Caseta SerenaRollerShade. +Support for Lutron Caseta shades. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.lutron_caseta/ """ import logging - from homeassistant.components.cover import ( - CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION) + CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION, + ATTR_POSITION, DOMAIN) from homeassistant.components.lutron_caseta import ( LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice) @@ -19,11 +19,10 @@ # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Lutron Caseta Serena shades as a cover device.""" + """Set up the Lutron Caseta shades as a cover device.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] - cover_devices = bridge.get_devices_by_types(["SerenaRollerShade", - "SerenaHoneycombShade"]) + cover_devices = bridge.get_devices_by_domain(DOMAIN) for cover_device in cover_devices: dev = LutronCasetaCover(cover_device, bridge) devs.append(dev) @@ -32,7 +31,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class LutronCasetaCover(LutronCasetaDevice, CoverDevice): - """Representation of a Lutron Serena shade.""" + """Representation of a Lutron shade.""" @property def supported_features(self): @@ -42,24 +41,26 @@ def supported_features(self): @property def is_closed(self): """Return if the cover is closed.""" - return self._state["current_state"] < 1 + return self._state['current_state'] < 1 @property def current_cover_position(self): """Return the current position of cover.""" - return self._state["current_state"] + return self._state['current_state'] - def close_cover(self): + def close_cover(self, **kwargs): """Close the cover.""" self._smartbridge.set_value(self._device_id, 0) - def open_cover(self): + def open_cover(self, **kwargs): """Open the cover.""" self._smartbridge.set_value(self._device_id, 100) - def set_cover_position(self, position, **kwargs): - """Move the roller shutter to a specific position.""" - self._smartbridge.set_value(self._device_id, position) + def set_cover_position(self, **kwargs): + """Move the shade to a specific position.""" + if ATTR_POSITION in kwargs: + position = kwargs[ATTR_POSITION] + self._smartbridge.set_value(self._device_id, position) def update(self): """Call when forcing a refresh of the device.""" diff --git a/homeassistant/components/cover/mysensors.py b/homeassistant/components/cover/mysensors.py index f48a2110ecab9..cd4ff62b3e905 100644 --- a/homeassistant/components/cover/mysensors.py +++ b/homeassistant/components/cover/mysensors.py @@ -4,42 +4,18 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.mysensors/ """ -import logging - from homeassistant.components import mysensors -from homeassistant.components.cover import CoverDevice, ATTR_POSITION +from homeassistant.components.cover import CoverDevice, ATTR_POSITION, DOMAIN from homeassistant.const import STATE_ON, STATE_OFF -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = [] - def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the MySensors platform for covers.""" - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_COVER: [set_req.V_DIMMER, set_req.V_LIGHT], - } - if float(gateway.protocol_version) >= 1.5: - map_sv_types.update({ - pres.S_COVER: [set_req.V_PERCENTAGE, set_req.V_STATUS], - }) - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, MySensorsCover, add_devices)) + """Setup the mysensors platform for covers.""" + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsCover, add_devices=add_devices) -class MySensorsCover(mysensors.MySensorsDeviceEntity, CoverDevice): +class MySensorsCover(mysensors.MySensorsEntity, CoverDevice): """Representation of the value of a MySensors Cover child node.""" @property diff --git a/homeassistant/components/cover/rfxtrx.py b/homeassistant/components/cover/rfxtrx.py index f599ea3ede143..0e28d3ef7017c 100644 --- a/homeassistant/components/cover/rfxtrx.py +++ b/homeassistant/components/cover/rfxtrx.py @@ -16,7 +16,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the RFXtrx cover.""" import RFXtrx as rfxtrxmod - covers = rfxtrx.get_devices_from_config(config, RfxtrxCover, hass) + covers = rfxtrx.get_devices_from_config(config, RfxtrxCover) add_devices_callback(covers) def cover_update(event): @@ -26,7 +26,7 @@ def cover_update(event): not event.device.known_to_be_rollershutter: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxCover, hass) + new_device = rfxtrx.get_new_device(event, config, RfxtrxCover) if new_device: add_devices_callback([new_device]) diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py index 769c2fc4ed63b..f9e059d392788 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -19,7 +19,7 @@ CONF_FRIENDLY_NAME, CONF_ENTITY_ID, EVENT_HOMEASSISTANT_START, MATCH_ALL, CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, - STATE_OPEN, STATE_CLOSED) + CONF_OPTIMISTIC, STATE_OPEN, STATE_CLOSED) from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id @@ -39,6 +39,8 @@ STOP_ACTION = 'stop_cover' POSITION_ACTION = 'set_cover_position' TILT_ACTION = 'set_cover_tilt_position' +CONF_TILT_OPTIMISTIC = 'tilt_optimistic' + CONF_VALUE_OR_POSITION_TEMPLATE = 'value_or_position' CONF_OPEN_OR_CLOSE = 'open_or_close' @@ -56,6 +58,8 @@ vol.Optional(CONF_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_TILT_TEMPLATE): cv.template, vol.Optional(CONF_ICON_TEMPLATE): cv.template, + vol.Optional(CONF_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_FRIENDLY_NAME, default=None): cv.string, @@ -83,11 +87,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): stop_action = device_config.get(STOP_ACTION) position_action = device_config.get(POSITION_ACTION) tilt_action = device_config.get(TILT_ACTION) - - if position_template is None and state_template is None: - _LOGGER.error('Must specify either %s' or '%s', - CONF_VALUE_TEMPLATE, CONF_VALUE_TEMPLATE) - continue + optimistic = device_config.get(CONF_OPTIMISTIC) + tilt_optimistic = device_config.get(CONF_TILT_OPTIMISTIC) if position_action is None and open_action is None: _LOGGER.error('Must specify at least one of %s' or '%s', @@ -125,7 +126,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): device, friendly_name, state_template, position_template, tilt_template, icon_template, open_action, close_action, stop_action, - position_action, tilt_action, entity_ids + position_action, tilt_action, + optimistic, tilt_optimistic, entity_ids ) ) if not covers: @@ -142,7 +144,8 @@ class CoverTemplate(CoverDevice): def __init__(self, hass, device_id, friendly_name, state_template, position_template, tilt_template, icon_template, open_action, close_action, stop_action, - position_action, tilt_action, entity_ids): + position_action, tilt_action, + optimistic, tilt_optimistic, entity_ids): """Initialize the Template cover.""" self.hass = hass self.entity_id = async_generate_entity_id( @@ -167,6 +170,9 @@ def __init__(self, hass, device_id, friendly_name, state_template, self._tilt_script = None if tilt_action is not None: self._tilt_script = Script(hass, tilt_action) + self._optimistic = (optimistic or + (not state_template and not position_template)) + self._tilt_optimistic = tilt_optimistic or not tilt_template self._icon = None self._position = None self._tilt_value = None @@ -260,19 +266,23 @@ def should_poll(self): def async_open_cover(self, **kwargs): """Move the cover up.""" if self._open_script: - self.hass.async_add_job(self._open_script.async_run()) + yield from self._open_script.async_run() elif self._position_script: - self.hass.async_add_job(self._position_script.async_run( - {"position": 100})) + yield from self._position_script.async_run({"position": 100}) + if self._optimistic: + self._position = 100 + self.hass.async_add_job(self.async_update_ha_state()) @asyncio.coroutine def async_close_cover(self, **kwargs): """Move the cover down.""" if self._close_script: - self.hass.async_add_job(self._close_script.async_run()) + yield from self._close_script.async_run() elif self._position_script: - self.hass.async_add_job(self._position_script.async_run( - {"position": 0})) + yield from self._position_script.async_run({"position": 0}) + if self._optimistic: + self._position = 0 + self.hass.async_add_job(self.async_update_ha_state()) @asyncio.coroutine def async_stop_cover(self, **kwargs): @@ -284,29 +294,35 @@ def async_stop_cover(self, **kwargs): def async_set_cover_position(self, **kwargs): """Set cover position.""" self._position = kwargs[ATTR_POSITION] - self.hass.async_add_job(self._position_script.async_run( - {"position": self._position})) + yield from self._position_script.async_run( + {"position": self._position}) + if self._optimistic: + self.hass.async_add_job(self.async_update_ha_state()) @asyncio.coroutine def async_open_cover_tilt(self, **kwargs): """Tilt the cover open.""" self._tilt_value = 100 - self.hass.async_add_job(self._tilt_script.async_run( - {"tilt": self._tilt_value})) + yield from self._tilt_script.async_run({"tilt": self._tilt_value}) + if self._tilt_optimistic: + self.hass.async_add_job(self.async_update_ha_state()) @asyncio.coroutine def async_close_cover_tilt(self, **kwargs): """Tilt the cover closed.""" self._tilt_value = 0 - self.hass.async_add_job(self._tilt_script.async_run( - {"tilt": self._tilt_value})) + yield from self._tilt_script.async_run( + {"tilt": self._tilt_value}) + if self._tilt_optimistic: + self.hass.async_add_job(self.async_update_ha_state()) @asyncio.coroutine def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" self._tilt_value = kwargs[ATTR_TILT_POSITION] - self.hass.async_add_job(self._tilt_script.async_run( - {"tilt": self._tilt_value})) + yield from self._tilt_script.async_run({"tilt": self._tilt_value}) + if self._tilt_optimistic: + self.hass.async_add_job(self.async_update_ha_state()) @asyncio.coroutine def async_update(self): diff --git a/homeassistant/components/cover/xiaomi.py b/homeassistant/components/cover/xiaomi.py index 7e3b0b7044d82..d0e7bfa6d7eb7 100644 --- a/homeassistant/components/cover/xiaomi.py +++ b/homeassistant/components/cover/xiaomi.py @@ -24,10 +24,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class XiaomiGenericCover(XiaomiDevice, CoverDevice): - """Representation of a XiaomiPlug.""" + """Representation of a XiaomiGenericCover.""" def __init__(self, device, name, data_key, xiaomi_hub): - """Initialize the XiaomiPlug.""" + """Initialize the XiaomiGenericCover.""" self._data_key = data_key self._pos = 0 XiaomiDevice.__init__(self, device, name, xiaomi_hub) @@ -44,19 +44,19 @@ def is_closed(self): def close_cover(self, **kwargs): """Close the cover.""" - self._write_to_hub(self._sid, self._data_key['status'], 'close') + self._write_to_hub(self._sid, **{self._data_key['status']: 'close'}) def open_cover(self, **kwargs): """Open the cover.""" - self._write_to_hub(self._sid, self._data_key['status'], 'open') + self._write_to_hub(self._sid, **{self._data_key['status']: 'open'}) def stop_cover(self, **kwargs): """Stop the cover.""" - self._write_to_hub(self._sid, self._data_key['status'], 'stop') + self._write_to_hub(self._sid, **{self._data_key['status']: 'stop'}) def set_cover_position(self, position, **kwargs): """Move the cover to a specific position.""" - self._write_to_hub(self._sid, self._data_key['pos'], str(position)) + self._write_to_hub(self._sid, **{self._data_key['pos']: str(position)}) def parse_data(self, data): """Parse data sent by gateway.""" diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 891f1b227758b..6ae038fd41c28 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -6,28 +6,32 @@ """ import asyncio from datetime import timedelta +import json import logging +import os +from aiohttp import web import voluptuous as vol from homeassistant.components.device_tracker import ( PLATFORM_SCHEMA, ATTR_ATTRIBUTES, ATTR_DEV_ID, ATTR_HOST_NAME, ATTR_MAC, ATTR_GPS, ATTR_GPS_ACCURACY) -from homeassistant.const import ( - CONF_USERNAME, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP, - EVENT_HOMEASSISTANT_START) +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval -REQUIREMENTS = ['aioautomatic==0.4.0'] +REQUIREMENTS = ['aioautomatic==0.6.2'] +DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) CONF_CLIENT_ID = 'client_id' CONF_SECRET = 'secret' CONF_DEVICES = 'devices' +CONF_CURRENT_LOCATION = 'current_location' DEFAULT_TIMEOUT = 5 @@ -38,38 +42,74 @@ EVENT_AUTOMATIC_UPDATE = 'automatic_update' +AUTOMATIC_CONFIG_FILE = '.automatic/session-{}.json' + +DATA_CONFIGURING = 'automatic_configurator_clients' +DATA_REFRESH_TOKEN = 'refresh_token' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_SECRET): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_CURRENT_LOCATION, default=False): cv.boolean, vol.Optional(CONF_DEVICES, default=None): vol.All( cv.ensure_list, [cv.string]) }) +def _get_refresh_token_from_file(hass, filename): + """Attempt to load session data from file.""" + path = hass.config.path(filename) + + if not os.path.isfile(path): + return None + + try: + with open(path) as data_file: + data = json.load(data_file) + if data is None: + return None + + return data.get(DATA_REFRESH_TOKEN) + except ValueError: + return None + + +def _write_refresh_token_to_file(hass, filename, refresh_token): + """Attempt to store session data to file.""" + path = hass.config.path(filename) + + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'w+') as data_file: + json.dump({ + DATA_REFRESH_TOKEN: refresh_token + }, data_file) + + @asyncio.coroutine def async_setup_scanner(hass, config, async_see, discovery_info=None): """Validate the configuration and return an Automatic scanner.""" import aioautomatic + hass.http.register_view(AutomaticAuthCallbackView()) + + scope = FULL_SCOPE if config.get(CONF_CURRENT_LOCATION) else DEFAULT_SCOPE + client = aioautomatic.Client( client_id=config[CONF_CLIENT_ID], client_secret=config[CONF_SECRET], client_session=async_get_clientsession(hass), request_kwargs={'timeout': DEFAULT_TIMEOUT}) - try: - try: - session = yield from client.create_session_from_password( - FULL_SCOPE, config[CONF_USERNAME], config[CONF_PASSWORD]) - except aioautomatic.exceptions.ForbiddenError as exc: - if not str(exc).startswith("invalid_scope"): - raise exc - _LOGGER.info("Client not authorized for current_location scope. " - "location:updated events will not be received.") - session = yield from client.create_session_from_password( - DEFAULT_SCOPE, config[CONF_USERNAME], config[CONF_PASSWORD]) + filename = AUTOMATIC_CONFIG_FILE.format(config[CONF_CLIENT_ID]) + refresh_token = yield from hass.async_add_job( + _get_refresh_token_from_file, hass, filename) + + @asyncio.coroutine + def initialize_data(session): + """Initialize the AutomaticData object from the created session.""" + hass.async_add_job( + _write_refresh_token_to_file, hass, filename, + session.refresh_token) data = AutomaticData( hass, client, session, config[CONF_DEVICES], async_see) @@ -77,26 +117,86 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): vehicles = yield from session.get_vehicles() for vehicle in vehicles: hass.async_add_job(data.load_vehicle(vehicle)) - except aioautomatic.exceptions.AutomaticError as err: - _LOGGER.error(str(err)) - return False - @callback - def ws_connect(event): - """Open the websocket connection.""" - hass.async_add_job(data.ws_connect()) + # Create a task instead of adding a tracking job, since this task will + # run until the websocket connection is closed. + hass.loop.create_task(data.ws_connect()) - @callback - def ws_close(event): - """Close the websocket connection.""" - hass.async_add_job(data.ws_close()) + if refresh_token is not None: + try: + session = yield from client.create_session_from_refresh_token( + refresh_token) + yield from initialize_data(session) + return True + except aioautomatic.exceptions.AutomaticError as err: + _LOGGER.error(str(err)) + + configurator = hass.components.configurator + request_id = configurator.async_request_config( + "Automatic", description=( + "Authorization required for Automatic device tracker."), + link_name="Click here to authorize Home Assistant.", + link_url=client.generate_oauth_url(scope), + entity_picture="/static/images/logo_automatic.png", + ) + + @asyncio.coroutine + def initialize_callback(code, state): + """Callback after OAuth2 response is returned.""" + try: + session = yield from client.create_session_from_oauth_code( + code, state) + yield from initialize_data(session) + configurator.async_request_done(request_id) + except aioautomatic.exceptions.AutomaticError as err: + _LOGGER.error(str(err)) + configurator.async_notify_errors(request_id, str(err)) + return False - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, ws_connect) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, ws_close) + if DATA_CONFIGURING not in hass.data: + hass.data[DATA_CONFIGURING] = {} + hass.data[DATA_CONFIGURING][client.state] = initialize_callback return True +class AutomaticAuthCallbackView(HomeAssistantView): + """Handle OAuth finish callback requests.""" + + requires_auth = False + url = '/api/automatic/callback' + name = 'api:automatic:callback' + + @callback + def get(self, request): # pylint: disable=no-self-use + """Finish OAuth callback request.""" + hass = request.app['hass'] + params = request.query + response = web.HTTPFound('/states') + + if 'state' not in params or 'code' not in params: + if 'error' in params: + _LOGGER.error( + "Error authorizing Automatic: %s", params['error']) + return response + else: + _LOGGER.error( + "Error authorizing Automatic. Invalid response returned.") + return response + + if DATA_CONFIGURING not in hass.data or \ + params['state'] not in hass.data[DATA_CONFIGURING]: + _LOGGER.error("Automatic configuration request not found.") + return response + + code = params['code'] + state = params['state'] + initialize_callback = hass.data[DATA_CONFIGURING][state] + hass.async_add_job(initialize_callback(code, state)) + + return response + + class AutomaticData(object): """A class representing an Automatic cloud service connection.""" @@ -105,6 +205,7 @@ def __init__(self, hass, client, session, devices, async_see): self.hass = hass self.devices = devices self.vehicle_info = {} + self.vehicle_seen = {} self.client = client self.session = session self.async_see = async_see @@ -115,6 +216,8 @@ def __init__(self, hass, client, session, devices, async_see): lambda name, event: self.hass.async_add_job( self.handle_event(name, event))) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.ws_close()) + @asyncio.coroutine def handle_event(self, name, event): """Coroutine to update state for a realtime event.""" @@ -134,6 +237,14 @@ def handle_event(self, name, event): return yield from self.get_vehicle_info(vehicle) + if event.created_at < self.vehicle_seen[event.vehicle.id]: + # Skip events received out of order + _LOGGER.debug("Skipping out of order event. Event Created %s. " + "Last seen event: %s.", event.created_at, + self.vehicle_seen[event.vehicle.id]) + return + self.vehicle_seen[event.vehicle.id] = event.created_at + kwargs = self.vehicle_info[event.vehicle.id] if kwargs is None: # Ignored device @@ -221,15 +332,17 @@ def get_vehicle_info(self, vehicle): if self.devices is not None and name not in self.devices: self.vehicle_info[vehicle.id] = None return - else: - self.vehicle_info[vehicle.id] = kwargs = { - ATTR_DEV_ID: vehicle.id, - ATTR_HOST_NAME: name, - ATTR_MAC: vehicle.id, - ATTR_ATTRIBUTES: { - ATTR_FUEL_LEVEL: vehicle.fuel_level_percent, - } + + self.vehicle_info[vehicle.id] = kwargs = { + ATTR_DEV_ID: vehicle.id, + ATTR_HOST_NAME: name, + ATTR_MAC: vehicle.id, + ATTR_ATTRIBUTES: { + ATTR_FUEL_LEVEL: vehicle.fuel_level_percent, } + } + self.vehicle_seen[vehicle.id] = \ + vehicle.updated_at or vehicle.created_at if vehicle.latest_location is not None: location = vehicle.latest_location @@ -250,4 +363,7 @@ def get_vehicle_info(self, vehicle): kwargs[ATTR_GPS] = (location.lat, location.lon) kwargs[ATTR_GPS_ACCURACY] = location.accuracy_m + if trips[0].ended_at >= self.vehicle_seen[vehicle.id]: + self.vehicle_seen[vehicle.id] = trips[0].ended_at + return kwargs diff --git a/homeassistant/components/device_tracker/geofency.py b/homeassistant/components/device_tracker/geofency.py new file mode 100755 index 0000000000000..d4e576bad7462 --- /dev/null +++ b/homeassistant/components/device_tracker/geofency.py @@ -0,0 +1,127 @@ +""" +Support for the Geofency platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.geofency/ +""" +import asyncio +from functools import partial +import logging + +import voluptuous as vol + +from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + ATTR_LATITUDE, ATTR_LONGITUDE, HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['http'] + +BEACON_DEV_PREFIX = 'beacon' +CONF_MOBILE_BEACONS = 'mobile_beacons' + +LOCATION_ENTRY = '1' +LOCATION_EXIT = '0' + +URL = '/api/geofency' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MOBILE_BEACONS): vol.All( + cv.ensure_list, [cv.string]), +}) + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up an endpoint for the Geofency application.""" + mobile_beacons = config.get(CONF_MOBILE_BEACONS) or [] + + hass.http.register_view(GeofencyView(see, mobile_beacons)) + + return True + + +class GeofencyView(HomeAssistantView): + """View to handle Geofency requests.""" + + url = URL + name = 'api:geofency' + + def __init__(self, see, mobile_beacons): + """Initialize Geofency url endpoints.""" + self.see = see + self.mobile_beacons = [slugify(beacon) for beacon in mobile_beacons] + + @asyncio.coroutine + def post(self, request): + """Handle Geofency requests.""" + data = yield from request.post() + hass = request.app['hass'] + + data = self._validate_data(data) + if not data: + return ("Invalid data", HTTP_UNPROCESSABLE_ENTITY) + + if self._is_mobile_beacon(data): + return (yield from self._set_location(hass, data, None)) + else: + if data['entry'] == LOCATION_ENTRY: + location_name = data['name'] + else: + location_name = STATE_NOT_HOME + + return (yield from self._set_location(hass, data, location_name)) + + @staticmethod + def _validate_data(data): + """Validate POST payload.""" + data = data.copy() + + required_attributes = ['address', 'device', 'entry', + 'latitude', 'longitude', 'name'] + + valid = True + for attribute in required_attributes: + if attribute not in data: + valid = False + _LOGGER.error("'%s' not specified in message", attribute) + + if not valid: + return False + + data['address'] = data['address'].replace('\n', ' ') + data['device'] = slugify(data['device']) + data['name'] = slugify(data['name']) + + data[ATTR_LATITUDE] = float(data[ATTR_LATITUDE]) + data[ATTR_LONGITUDE] = float(data[ATTR_LONGITUDE]) + + return data + + def _is_mobile_beacon(self, data): + """Check if we have a mobile beacon.""" + return 'beaconUUID' in data and data['name'] in self.mobile_beacons + + @staticmethod + def _device_name(data): + """Return name of device tracker.""" + if 'beaconUUID' in data: + return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) + else: + return data['device'] + + @asyncio.coroutine + def _set_location(self, hass, data, location_name): + """Fire HA event to set location.""" + device = self._device_name(data) + + yield from hass.async_add_job( + partial(self.see, dev_id=device, + gps=(data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), + location_name=location_name, + attributes=data)) + + return "Setting location for {}".format(device) diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index 194a2f4bfac27..e670287dd879d 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -19,7 +19,6 @@ from homeassistant.util import slugify import homeassistant.util.dt as dt_util from homeassistant.util.location import distance -from homeassistant.loader import get_component _LOGGER = logging.getLogger(__name__) @@ -209,7 +208,7 @@ def icloud_trusted_device_callback(self, callback_data): if self.accountname in _CONFIGURING: request_id = _CONFIGURING.pop(self.accountname) - configurator = get_component('configurator') + configurator = self.hass.components.configurator configurator.request_done(request_id) # Trigger the next step immediately @@ -217,7 +216,7 @@ def icloud_trusted_device_callback(self, callback_data): def icloud_need_trusted_device(self): """We need a trusted device.""" - configurator = get_component('configurator') + configurator = self.hass.components.configurator if self.accountname in _CONFIGURING: return @@ -229,7 +228,7 @@ def icloud_need_trusted_device(self): devicesstring += "{}: {};".format(i, devicename) _CONFIGURING[self.accountname] = configurator.request_config( - self.hass, 'iCloud {}'.format(self.accountname), + 'iCloud {}'.format(self.accountname), self.icloud_trusted_device_callback, description=( 'Please choose your trusted device by entering' @@ -259,17 +258,17 @@ def icloud_verification_callback(self, callback_data): if self.accountname in _CONFIGURING: request_id = _CONFIGURING.pop(self.accountname) - configurator = get_component('configurator') + configurator = self.hass.components.configurator configurator.request_done(request_id) def icloud_need_verification_code(self): """Return the verification code.""" - configurator = get_component('configurator') + configurator = self.hass.components.configurator if self.accountname in _CONFIGURING: return _CONFIGURING[self.accountname] = configurator.request_config( - self.hass, 'iCloud {}'.format(self.accountname), + 'iCloud {}'.format(self.accountname), self.icloud_verification_callback, description=('Please enter the validation code:'), entity_picture="/static/images/config_icloud.png", @@ -308,12 +307,15 @@ def keep_alive(self, now): self.api.authenticate() currentminutes = dt_util.now().hour * 60 + dt_util.now().minute - for devicename in self.devices: - interval = self._intervals.get(devicename, 1) - if ((currentminutes % interval == 0) or - (interval > 10 and - currentminutes % interval in [2, 4])): - self.update_device(devicename) + try: + for devicename in self.devices: + interval = self._intervals.get(devicename, 1) + if ((currentminutes % interval == 0) or + (interval > 10 and + currentminutes % interval in [2, 4])): + self.update_device(devicename) + except ValueError: + _LOGGER.debug("iCloud API returned an error") def determine_interval(self, devicename, latitude, longitude, battery): """Calculate new interval.""" @@ -398,7 +400,7 @@ def update_device(self, devicename): self.see(**kwargs) self.seen_devices[devicename] = True except PyiCloudNoDevicesException: - _LOGGER.error('No iCloud Devices found!') + _LOGGER.error("No iCloud Devices found") def lost_iphone(self, devicename): """Call the lost iPhone function if the device is found.""" diff --git a/homeassistant/components/device_tracker/mysensors.py b/homeassistant/components/device_tracker/mysensors.py index 4503c4d1b2630..f68eb361ca097 100644 --- a/homeassistant/components/device_tracker/mysensors.py +++ b/homeassistant/components/device_tracker/mysensors.py @@ -4,61 +4,51 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.mysensors/ """ -import logging - from homeassistant.components import mysensors +from homeassistant.components.device_tracker import DOMAIN +from homeassistant.helpers.dispatcher import dispatcher_connect from homeassistant.util import slugify -DEPENDENCIES = ['mysensors'] -_LOGGER = logging.getLogger(__name__) +def setup_scanner(hass, config, see, discovery_info=None): + """Set up the MySensors device scanner.""" + new_devices = mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsDeviceScanner, + device_args=(see, )) + if not new_devices: + return False + + for device in new_devices: + dev_id = ( + id(device.gateway), device.node_id, device.child_id, + device.value_type) + dispatcher_connect( + hass, mysensors.SIGNAL_CALLBACK.format(*dev_id), + device.update_callback) + return True -def setup_scanner(hass, config, see, discovery_info=None): - """Set up the MySensors tracker.""" - def mysensors_callback(gateway, msg): - """Set up callback for mysensors platform.""" - node = gateway.sensors[msg.node_id] - if node.sketch_name is None: - _LOGGER.debug("No sketch_name: node %s", msg.node_id) - return - pres = gateway.const.Presentation - set_req = gateway.const.SetReq +class MySensorsDeviceScanner(mysensors.MySensorsDevice): + """Represent a MySensors scanner.""" + + def __init__(self, see, *args): + """Set up instance.""" + super().__init__(*args) + self.see = see - child = node.children.get(msg.child_id) - if child is None: - return - position = child.values.get(set_req.V_POSITION) - if child.type != pres.S_GPS or position is None: - return - try: - latitude, longitude, _ = position.split(',') - except ValueError: - _LOGGER.error("Payload for V_POSITION %s is not of format " - "latitude, longitude, altitude", position) - return - name = '{} {} {}'.format( - node.sketch_name, msg.node_id, child.id) - attr = { - mysensors.ATTR_CHILD_ID: child.id, - mysensors.ATTR_DESCRIPTION: child.description, - mysensors.ATTR_DEVICE: gateway.device, - mysensors.ATTR_NODE_ID: msg.node_id, - } - see( - dev_id=slugify(name), - host_name=name, + def update_callback(self): + """Update the device.""" + self.update() + node = self.gateway.sensors[self.node_id] + child = node.children[self.child_id] + position = child.values[self.value_type] + latitude, longitude, _ = position.split(',') + + self.see( + dev_id=slugify(self.name), + host_name=self.name, gps=(latitude, longitude), battery=node.battery_level, - attributes=attr + attributes=self.device_state_attributes ) - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - - for gateway in gateways: - if float(gateway.protocol_version) < 2.0: - continue - gateway.platform_callbacks.append(mysensors_callback) - - return True diff --git a/homeassistant/components/device_tracker/tesla.py b/homeassistant/components/device_tracker/tesla.py new file mode 100644 index 0000000000000..4945e98a94d6c --- /dev/null +++ b/homeassistant/components/device_tracker/tesla.py @@ -0,0 +1,57 @@ +""" +Support for the Tesla platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.tesla/ +""" +import logging + +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN +from homeassistant.helpers.event import track_utc_time_change +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up the Tesla tracker.""" + TeslaDeviceTracker( + hass, config, see, + hass.data[TESLA_DOMAIN]['devices']['devices_tracker']) + return True + + +class TeslaDeviceTracker(object): + """A class representing a Tesla device.""" + + def __init__(self, hass, config, see, tesla_devices): + """Initialize the Tesla device scanner.""" + self.hass = hass + self.see = see + self.devices = tesla_devices + self._update_info() + + track_utc_time_change( + self.hass, self._update_info, second=range(0, 60, 30)) + + def _update_info(self, now=None): + """Update the device info.""" + for device in self.devices: + device.update() + name = device.name + _LOGGER.debug("Updating device position: %s", name) + dev_id = slugify(device.uniq_name) + location = device.get_location() + lat = location['latitude'] + lon = location['longitude'] + attrs = { + 'trackr_id': dev_id, + 'id': dev_id, + 'name': name + } + self.see( + dev_id=dev_id, host_name=name, + gps=(lat, lon), attributes=attrs + ) diff --git a/homeassistant/components/device_tracker/volvooncall.py b/homeassistant/components/device_tracker/volvooncall.py index 4312c5dd54a19..7872f8f1f1c89 100644 --- a/homeassistant/components/device_tracker/volvooncall.py +++ b/homeassistant/components/device_tracker/volvooncall.py @@ -20,11 +20,12 @@ def setup_scanner(hass, config, see, discovery_info=None): return vin, _ = discovery_info - vehicle = hass.data[DATA_KEY].vehicles[vin] + voc = hass.data[DATA_KEY] + vehicle = voc.vehicles[vin] def see_vehicle(vehicle): """Handle the reporting of the vehicle position.""" - host_name = vehicle.registration_number + host_name = voc.vehicle_name(vehicle) dev_id = 'volvo_{}'.format(slugify(host_name)) see(dev_id=dev_id, host_name=host_name, diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 06e6f0b989a45..c757d9d1ce306 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -100,6 +100,7 @@ def new_service_found(service, info): # We do not know how to handle this service. if not comp_plat: + logger.info("Unknown service discovered: %s %s", service, info) return discovery_hash = json.dumps([service, info], sort_keys=True) diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index f0c95f7de3db0..c4b0f2e9546e4 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -13,10 +13,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery from homeassistant.const import CONF_API_KEY -from homeassistant.loader import get_component from homeassistant.util import Throttle -REQUIREMENTS = ['python-ecobee-api==0.0.7'] +REQUIREMENTS = ['python-ecobee-api==0.0.9'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) @@ -41,7 +40,7 @@ def request_configuration(network, hass, config): """Request configuration steps from the user.""" - configurator = get_component('configurator') + configurator = hass.components.configurator if 'ecobee' in _CONFIGURING: configurator.notify_errors( _CONFIGURING['ecobee'], "Failed to register, please try again.") @@ -56,7 +55,7 @@ def ecobee_configuration_callback(callback_data): setup_ecobee(hass, network, config) _CONFIGURING['ecobee'] = configurator.request_config( - hass, "Ecobee", ecobee_configuration_callback, + "Ecobee", ecobee_configuration_callback, description=( 'Please authorize this app at https://www.ecobee.com/consumer' 'portal/index.html with pin code: ' + network.pin), @@ -73,7 +72,7 @@ def setup_ecobee(hass, network, config): return if 'ecobee' in _CONFIGURING: - configurator = get_component('configurator') + configurator = hass.components.configurator configurator.request_done(_CONFIGURING.pop('ecobee')) hold_temp = config[DOMAIN].get(CONF_HOLD_TEMP) diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index 31d8ab60e3092..f8d414240649c 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -2,7 +2,6 @@ import threading import socket import logging -import os import select from aiohttp import web @@ -86,18 +85,6 @@ def __init__(self, host_ip_addr, listen_port, upnp_bind_multicast, advertise_ip, advertise_port).replace("\n", "\r\n") \ .encode('utf-8') - # Set up a pipe for signaling to the receiver that it's time to - # shutdown. Essentially, we place the SSDP socket into nonblocking - # mode and use select() to wait for data to arrive on either the SSDP - # socket or the pipe. If data arrives on either one, select() returns - # and tells us which filenos have data ready to read. - # - # When we want to stop the responder, we write data to the pipe, which - # causes the select() to return and indicate that said pipe has data - # ready to be read, which indicates to us that the responder needs to - # be shutdown. - self._interrupted_read_pipe, self._interrupted_write_pipe = os.pipe() - def run(self): """Run the server.""" # Listen for UDP port 1900 packets sent to SSDP multicast address @@ -119,7 +106,7 @@ def run(self): socket.inet_aton(self.host_ip_addr)) if self.upnp_bind_multicast: - ssdp_socket.bind(("239.255.255.250", 1900)) + ssdp_socket.bind(("", 1900)) else: ssdp_socket.bind((self.host_ip_addr, 1900)) @@ -130,16 +117,13 @@ def run(self): try: read, _, _ = select.select( - [self._interrupted_read_pipe, ssdp_socket], [], - [ssdp_socket]) + [ssdp_socket], [], + [ssdp_socket], 2) - if self._interrupted_read_pipe in read: - # Implies self._interrupted is True - clean_socket_close(ssdp_socket) - return - elif ssdp_socket in read: + if ssdp_socket in read: data, addr = ssdp_socket.recvfrom(1024) else: + # most likely the timeout, so check for interupt continue except socket.error as ex: if self._interrupted: @@ -148,6 +132,9 @@ def run(self): _LOGGER.error("UPNP Responder socket exception occured: %s", ex.__str__) + # without the following continue, a second exception occurs + # because the data object has not been initialized + continue if "M-SEARCH" in data.decode('utf-8'): # SSDP M-SEARCH method received, respond to it with our info @@ -161,7 +148,6 @@ def stop(self): """Stop the server.""" # Request for server self._interrupted = True - os.write(self._interrupted_write_pipe, bytes([0])) self.join() diff --git a/homeassistant/components/envisalink.py b/homeassistant/components/envisalink.py index 87b6163282acb..5ffd97ef0e31d 100644 --- a/homeassistant/components/envisalink.py +++ b/homeassistant/components/envisalink.py @@ -16,7 +16,7 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send -REQUIREMENTS = ['pyenvisalink==2.1'] +REQUIREMENTS = ['pyenvisalink==2.2'] _LOGGER = logging.getLogger(__name__) @@ -74,9 +74,9 @@ vol.All(vol.Coerce(int), vol.Range(min=3, max=4)), vol.Optional(CONF_EVL_KEEPALIVE, default=DEFAULT_KEEPALIVE): vol.All(vol.Coerce(int), vol.Range(min=15)), - vol.Optional(CONF_ZONEDUMP_INTERVAL, - default=DEFAULT_ZONEDUMP_INTERVAL): - vol.All(vol.Coerce(int), vol.Range(min=15)), + vol.Optional( + CONF_ZONEDUMP_INTERVAL, + default=DEFAULT_ZONEDUMP_INTERVAL): vol.Coerce(int), }), }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/fan/insteon_local.py b/homeassistant/components/fan/insteon_local.py index a18c173ecca63..5bdfec084279a 100644 --- a/homeassistant/components/fan/insteon_local.py +++ b/homeassistant/components/fan/insteon_local.py @@ -13,7 +13,6 @@ ATTR_SPEED, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED, FanEntity) from homeassistant.helpers.entity import ToggleEntity -from homeassistant.loader import get_component import homeassistant.util as util _CONFIGURING = {} @@ -57,7 +56,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): def request_configuration(device_id, insteonhub, model, hass, add_devices_callback): """Request configuration steps from the user.""" - configurator = get_component('configurator') + configurator = hass.components.configurator # We got an error if this method is called while we are configuring if device_id in _CONFIGURING: @@ -72,7 +71,7 @@ def insteon_fan_config_callback(data): add_devices_callback) _CONFIGURING[device_id] = configurator.request_config( - hass, 'Insteon ' + model + ' addr: ' + device_id, + 'Insteon ' + model + ' addr: ' + device_id, insteon_fan_config_callback, description=('Enter a name for ' + model + ' Fan addr: ' + device_id), entity_picture='/static/images/config_insteon.png', @@ -85,7 +84,7 @@ def setup_fan(device_id, name, insteonhub, hass, add_devices_callback): """Set up the fan.""" if device_id in _CONFIGURING: request_id = _CONFIGURING.pop(device_id) - configurator = get_component('configurator') + configurator = hass.components.configurator configurator.request_done(request_id) _LOGGER.info("Device configuration done!") diff --git a/homeassistant/components/fan/isy994.py b/homeassistant/components/fan/isy994.py index 8b9236fdb32a0..90cd161fa2000 100644 --- a/homeassistant/components/fan/isy994.py +++ b/homeassistant/components/fan/isy994.py @@ -16,6 +16,11 @@ _LOGGER = logging.getLogger(__name__) +# Define term used for medium speed. This must be set as the fan component uses +# 'medium' which the ISY does not understand +ISY_SPEED_MEDIUM = 'med' + + VALUE_TO_STATE = { 0: SPEED_OFF, 63: SPEED_LOW, @@ -29,7 +34,7 @@ for key in VALUE_TO_STATE: STATE_TO_VALUE[VALUE_TO_STATE[key]] = key -STATES = [SPEED_OFF, SPEED_LOW, 'med', SPEED_HIGH] +STATES = [SPEED_OFF, SPEED_LOW, ISY_SPEED_MEDIUM, SPEED_HIGH] # pylint: disable=unused-argument @@ -93,6 +98,11 @@ def turn_off(self, **kwargs) -> None: else: self.speed = self.state + @property + def speed_list(self) -> list: + """Get the list of available speeds.""" + return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + class ISYFanProgram(ISYFanDevice): """Representation of an ISY994 fan program.""" diff --git a/homeassistant/components/ffmpeg.py b/homeassistant/components/ffmpeg.py index 45bd651ad95b6..887d07e5855a1 100644 --- a/homeassistant/components/ffmpeg.py +++ b/homeassistant/components/ffmpeg.py @@ -19,7 +19,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['ha-ffmpeg==1.5'] +REQUIREMENTS = ['ha-ffmpeg==1.7'] DOMAIN = 'ffmpeg' diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 29f6ef577e5c2..112c93403b007 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -28,6 +28,7 @@ STATIC_PATH = os.path.join(os.path.dirname(__file__), 'www_static/') ATTR_THEMES = 'themes' +ATTR_EXTRA_HTML_URL = 'extra_html_url' DEFAULT_THEME_COLOR = '#03A9F4' MANIFEST_JSON = { 'background_color': '#FFFFFF', @@ -50,6 +51,7 @@ }) DATA_PANELS = 'frontend_panels' +DATA_EXTRA_HTML_URL = 'frontend_extra_html_url' DATA_INDEX_VIEW = 'frontend_index_view' DATA_THEMES = 'frontend_themes' DATA_DEFAULT_THEME = 'frontend_default_theme' @@ -66,6 +68,8 @@ vol.Optional(ATTR_THEMES): vol.Schema({ cv.string: {cv.string: cv.string} }), + vol.Optional(ATTR_EXTRA_HTML_URL): + vol.All(cv.ensure_list, [cv.string]), }), }, extra=vol.ALLOW_EXTRA) @@ -105,14 +109,13 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, component_name: name of the web component path: path to the HTML of the web component + (required unless url is provided) md5: the md5 hash of the web component (for versioning, optional) sidebar_title: title to show in the sidebar (optional) sidebar_icon: icon to show next to title in sidebar (optional) url_path: name to use in the url (defaults to component_name) - url: for the web component (for dev environment, optional) + url: for the web component (optional) config: config to be passed into the web component - - Warning: this API will probably change. Use at own risk. """ panels = hass.data.get(DATA_PANELS) if panels is None: @@ -123,14 +126,16 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, if url_path in panels: _LOGGER.warning("Overwriting component %s", url_path) - if not os.path.isfile(path): - _LOGGER.error( - "Panel %s component does not exist: %s", component_name, path) - return - if md5 is None: - with open(path) as fil: - md5 = hashlib.md5(fil.read().encode('utf-8')).hexdigest() + if url is None: + if not os.path.isfile(path): + _LOGGER.error( + "Panel %s component does not exist: %s", component_name, path) + return + + if md5 is None: + with open(path) as fil: + md5 = hashlib.md5(fil.read().encode('utf-8')).hexdigest() data = { 'url_path': url_path, @@ -169,6 +174,15 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, 'get', '/{}/{{extra:.+}}'.format(url_path), index_view.get) +@bind_hass +def add_extra_html_url(hass, url): + """Register extra html url to load.""" + url_set = hass.data.get(DATA_EXTRA_HTML_URL) + if url_set is None: + url_set = hass.data[DATA_EXTRA_HTML_URL] = set() + url_set.add(url) + + def add_manifest_json_key(key, val): """Add a keyval to the manifest.json.""" MANIFEST_JSON[key] = val @@ -208,6 +222,9 @@ def setup(hass, config): else: hass.data[DATA_PANELS] = {} + if DATA_EXTRA_HTML_URL not in hass.data: + hass.data[DATA_EXTRA_HTML_URL] = set() + register_built_in_panel(hass, 'map', 'Map', 'mdi:account-location') for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', @@ -217,6 +234,9 @@ def setup(hass, config): themes = config.get(DOMAIN, {}).get(ATTR_THEMES) setup_themes(hass, themes) + for url in config.get(DOMAIN, {}).get(ATTR_EXTRA_HTML_URL, []): + add_extra_html_url(hass, url) + return True @@ -362,7 +382,9 @@ def get(self, request, extra=None): compatibility_url=compatibility_url, no_auth=no_auth, icons_url=icons_url, icons=FINGERPRINTS['mdi.html'], panel_url=panel_url, panels=hass.data[DATA_PANELS], - dev_mode=request.app[KEY_DEVELOPMENT]) + dev_mode=request.app[KEY_DEVELOPMENT], + theme_color=MANIFEST_JSON['theme_color'], + extra_urls=hass.data[DATA_EXTRA_HTML_URL]) return web.Response(text=resp, content_type='text/html') diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html index 6420bb797391b..6d199a86a507c 100644 --- a/homeassistant/components/frontend/templates/index.html +++ b/homeassistant/components/frontend/templates/index.html @@ -21,7 +21,7 @@ - + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz index e794d3735977c..08a7f5002cd0f 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html index d55e008d907d2..53638dd582b07 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html @@ -1,2 +1,2 @@ \ No newline at end of file + clear: both;white-space:pre-wrap;}.rendered.error{color:red;}
Templates

Templates are rendered using the Jinja2 template engine with some Home Assistant specific extensions.

[[processed]]
\ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz index 9d8f4d9f5eb8e..24fd95f17a7b7 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html index e2a93ae1cea70..5f34f7bc28a62 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz index 801450f1bd8d9..d9dd4c687fbc9 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz differ diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index 21824a1232667..dc4770853e0b8 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -37,7 +37,7 @@ /* eslint-disable indent, no-unused-vars, no-multiple-empty-lines, max-nested-callbacks, space-before-function-paren, quotes, comma-spacing */ 'use strict'; -var precacheConfig = [["/","07ae53d16e9e97de8c721f5032cf79bf"],["/frontend/panels/dev-event-d409e7ab537d9fe629126d122345279c.html","936814991f2a5e23d61d29f0d40f81b8"],["/frontend/panels/dev-info-b0e55eb657fd75f21aba2426ac0cedc0.html","1fa953b0224470f70d4e87bbe4dff191"],["/frontend/panels/dev-mqtt-94b222b013a98583842de3e72d5888c6.html","dc3ddfac58397feda97317358f0aecbb"],["/frontend/panels/dev-service-422b2c181ee0713fa31d45a64e605baf.html","ae7d26b1c8c3309fd3c65944f89ea03f"],["/frontend/panels/dev-state-7948d3dba058f31517d880df8ed0e857.html","ff8156bb1a52490fcc07466556fce0e1"],["/frontend/panels/dev-template-f47b6910d8e4880e22cc508ca452f9b6.html","9aa0675e01373c6bc2737438bb84a9ec"],["/frontend/panels/map-c2544fff3eedb487d44105cf94b335ec.html","113c5bf9a68a74c62e50cd354034e78b"],["/static/compatibility-1686167ff210e001f063f5c606b2e74b.js","6ee7b5e2dd82b510c3bd92f7e215988e"],["/static/core-2a7d01e45187c7d4635da05065b5e54e.js","90a0a8a6a6dd0ca41b16f40e7d23924d"],["/static/frontend-5a2a3d6181cc820f5b3e94d1a50def74.html","6cd425233aeb180178dccae238533d65"],["/static/mdi-e91f61a039ed0a9936e7ee5360da3870.html","5e587bc82719b740a4f0798722a83aee"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"]]; +var precacheConfig = [["/","eceffe0debe81636e1eb8604e6eefbd6"],["/frontend/panels/dev-event-d409e7ab537d9fe629126d122345279c.html","936814991f2a5e23d61d29f0d40f81b8"],["/frontend/panels/dev-info-b0e55eb657fd75f21aba2426ac0cedc0.html","1fa953b0224470f70d4e87bbe4dff191"],["/frontend/panels/dev-mqtt-94b222b013a98583842de3e72d5888c6.html","dc3ddfac58397feda97317358f0aecbb"],["/frontend/panels/dev-service-422b2c181ee0713fa31d45a64e605baf.html","ae7d26b1c8c3309fd3c65944f89ea03f"],["/frontend/panels/dev-state-7948d3dba058f31517d880df8ed0e857.html","ff8156bb1a52490fcc07466556fce0e1"],["/frontend/panels/dev-template-928e7b81b9c113b70edc9f4a1d051827.html","312c8313800b44c83bcb8dc2df30c759"],["/frontend/panels/map-565db019147162080c21af962afc097f.html","a1a360042395682335e2f471dddad309"],["/static/compatibility-1686167ff210e001f063f5c606b2e74b.js","6ee7b5e2dd82b510c3bd92f7e215988e"],["/static/core-2a7d01e45187c7d4635da05065b5e54e.js","90a0a8a6a6dd0ca41b16f40e7d23924d"],["/static/frontend-c04709d3517dd3fd34b2f7d6bba6ec8e.html","e072f7bbe595bcb104d117a45592459d"],["/static/mdi-89074face5529f5fe6fbae49ecb3e88b.html","97754e463f9e56a95c813d4d8e792347"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"]]; var cacheName = 'sw-precache-v3--' + (self.registration ? self.registration.scope : ''); diff --git a/homeassistant/components/frontend/www_static/service_worker.js.gz b/homeassistant/components/frontend/www_static/service_worker.js.gz index 980bc5d26a82f..14edb98db2b16 100644 Binary files a/homeassistant/components/frontend/www_static/service_worker.js.gz and b/homeassistant/components/frontend/www_static/service_worker.js.gz differ diff --git a/homeassistant/components/graphite.py b/homeassistant/components/graphite.py index fc2a196c4c68b..e4626d0f016e0 100644 --- a/homeassistant/components/graphite.py +++ b/homeassistant/components/graphite.py @@ -45,13 +45,12 @@ def setup(hass, config): try: sock.connect((host, port)) sock.shutdown(2) - _LOGGER.debug('Connection to Graphite possible') + _LOGGER.debug("Connection to Graphite possible") except socket.error: - _LOGGER.error('Not able to connect to Graphite') + _LOGGER.error("Not able to connect to Graphite") return False GraphiteFeeder(hass, host, port, prefix) - return True @@ -143,15 +142,15 @@ def run(self): _LOGGER.debug("Processing STATE_CHANGED event for %s", event.data['entity_id']) try: - self._report_attributes(event.data['entity_id'], - event.data['new_state']) + self._report_attributes( + event.data['entity_id'], event.data['new_state']) # pylint: disable=broad-except except Exception: # Catch this so we can avoid the thread dying and # make it visible. _LOGGER.exception("Failed to process STATE_CHANGED event") else: - _LOGGER.warning("Processing unexpected event type %s", - event.event_type) + _LOGGER.warning( + "Processing unexpected event type %s", event.event_type) self._queue.task_done() diff --git a/homeassistant/components/hdmi_cec.py b/homeassistant/components/hdmi_cec.py index 9989b2799cdab..b4233f1ac82df 100644 --- a/homeassistant/components/hdmi_cec.py +++ b/homeassistant/components/hdmi_cec.py @@ -31,7 +31,7 @@ _LOGGER = logging.getLogger(__name__) -DEFAULT_DISPLAY_NAME = "HomeAssistant" +DEFAULT_DISPLAY_NAME = "HA" CONF_TYPES = 'types' ICON_UNKNOWN = 'mdi:help' @@ -181,7 +181,7 @@ def setup(hass: HomeAssistant, base_config): if host: adapter = TcpAdapter(host, name=display_name, activate_source=False) else: - adapter = CecAdapter(name=display_name, activate_source=False) + adapter = CecAdapter(name=display_name[:12], activate_source=False) hdmi_network = HDMINetwork(adapter, loop=loop) def _volume(call): diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic.py index f9583d9be7ae0..dc5e641cbbaab 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic.py @@ -4,8 +4,8 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/homematic/ """ +import asyncio import os -import time import logging from datetime import timedelta from functools import partial @@ -18,7 +18,7 @@ CONF_PLATFORM, CONF_HOSTS, CONF_NAME, ATTR_ENTITY_ID) from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.event import async_track_time_interval from homeassistant.config import load_yaml_config_file REQUIREMENTS = ['pyhomematic==0.1.30'] @@ -121,7 +121,6 @@ ] DATA_HOMEMATIC = 'homematic' -DATA_DELAY = 'homematic_delay' DATA_DEVINIT = 'homematic_devinit' DATA_STORE = 'homematic_store' @@ -134,7 +133,6 @@ CONF_RESOLVENAMES = 'resolvenames' CONF_VARIABLES = 'variables' CONF_DEVICES = 'devices' -CONF_DELAY = 'delay' CONF_PRIMARY = 'primary' DEFAULT_LOCAL_IP = '0.0.0.0' @@ -145,7 +143,6 @@ DEFAULT_PASSWORD = '' DEFAULT_VARIABLES = False DEFAULT_DEVICES = True -DEFAULT_DELAY = 0.5 DEFAULT_PRIMARY = False @@ -177,7 +174,6 @@ }}, vol.Optional(CONF_LOCAL_IP, default=DEFAULT_LOCAL_IP): cv.string, vol.Optional(CONF_LOCAL_PORT, default=DEFAULT_LOCAL_PORT): cv.port, - vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): vol.Coerce(float), }), }, extra=vol.ALLOW_EXTRA) @@ -249,7 +245,6 @@ def setup(hass, config): """Set up the Homematic component.""" from pyhomematic import HMConnection - hass.data[DATA_DELAY] = config[DOMAIN].get(CONF_DELAY) hass.data[DATA_DEVINIT] = {} hass.data[DATA_STORE] = set() @@ -277,7 +272,7 @@ def setup(hass, config): # Create server thread bound_system_callback = partial(_system_callback_handler, hass, config) - hass.data[DATA_HOMEMATIC] = HMConnection( + hass.data[DATA_HOMEMATIC] = homematic = HMConnection( local=config[DOMAIN].get(CONF_LOCAL_IP), localport=config[DOMAIN].get(CONF_LOCAL_PORT), remotes=remotes, @@ -286,7 +281,7 @@ def setup(hass, config): ) # Start server thread, connect to hosts, initialize to receive events - hass.data[DATA_HOMEMATIC].start() + homematic.start() # Stops server when HASS is shutting down hass.bus.listen_once( @@ -296,7 +291,7 @@ def setup(hass, config): entity_hubs = [] for _, hub_data in hosts.items(): entity_hubs.append(HMHub( - hass, hub_data[CONF_NAME], hub_data[CONF_VARIABLES])) + homematic, hub_data[CONF_NAME], hub_data[CONF_VARIABLES])) # Register HomeMatic services descriptions = load_yaml_config_file( @@ -359,7 +354,7 @@ def _service_handle_value(service): def _service_handle_reconnect(service): """Service to reconnect all HomeMatic hubs.""" - hass.data[DATA_HOMEMATIC].reconnect() + homematic.reconnect() hass.services.register( DOMAIN, SERVICE_RECONNECT, _service_handle_reconnect, @@ -575,24 +570,27 @@ def _device_from_servicecall(hass, service): class HMHub(Entity): """The HomeMatic hub. (CCU2/HomeGear).""" - def __init__(self, hass, name, use_variables): + def __init__(self, homematic, name, use_variables): """Initialize HomeMatic hub.""" - self.hass = hass self.entity_id = "{}.{}".format(DOMAIN, name.lower()) - self._homematic = hass.data[DATA_HOMEMATIC] + self._homematic = homematic self._variables = {} self._name = name self._state = STATE_UNKNOWN self._use_variables = use_variables + @asyncio.coroutine + def async_added_to_hass(self): + """Load data init callbacks.""" # Load data - track_time_interval(hass, self._update_hub, SCAN_INTERVAL_HUB) - self._update_hub(None) + async_track_time_interval( + self.hass, self._update_hub, SCAN_INTERVAL_HUB) + yield from self.hass.async_add_job(self._update_hub, None) if self._use_variables: - track_time_interval( - hass, self._update_variables, SCAN_INTERVAL_VARIABLES) - self._update_variables(None) + async_track_time_interval( + self.hass, self._update_variables, SCAN_INTERVAL_VARIABLES) + yield from self.hass.async_add_job(self._update_variables, None) @property def name(self): @@ -624,7 +622,9 @@ def _update_hub(self, now): """Retrieve latest state.""" state = self._homematic.getServiceMessages(self._name) self._state = STATE_UNKNOWN if state is None else len(state) - self.schedule_update_ha_state() + + if now: + self.schedule_update_ha_state() def _update_variables(self, now): """Retrive all variable data and update hmvariable states.""" @@ -640,7 +640,7 @@ def _update_variables(self, now): state_change = True self._variables.update({key: value}) - if state_change: + if state_change and now: self.schedule_update_ha_state() def hm_set_variable(self, name, value): @@ -662,16 +662,15 @@ def hm_set_variable(self, name, value): class HMDevice(Entity): """The HomeMatic device base object.""" - def __init__(self, hass, config): + def __init__(self, config): """Initialize a generic HomeMatic device.""" - self.hass = hass - self._homematic = hass.data[DATA_HOMEMATIC] self._name = config.get(ATTR_NAME) self._address = config.get(ATTR_ADDRESS) self._proxy = config.get(ATTR_PROXY) self._channel = config.get(ATTR_CHANNEL) self._state = config.get(ATTR_PARAM) self._data = {} + self._homematic = None self._hmdevice = None self._connected = False self._available = False @@ -680,6 +679,11 @@ def __init__(self, hass, config): if self._state: self._state = self._state.upper() + @asyncio.coroutine + def async_added_to_hass(self): + """Load data init callbacks.""" + yield from self.hass.async_add_job(self.link_homematic) + @property def should_poll(self): """Return false. HomeMatic states are pushed by the XML-RPC Server.""" @@ -728,16 +732,13 @@ def link_homematic(self): return True # Initialize + self._homematic = self.hass.data[DATA_HOMEMATIC] self._hmdevice = self._homematic.devices[self._proxy][self._address] self._connected = True try: # Initialize datapoints of this object self._init_data() - if self.hass.data[DATA_DELAY]: - # We optionally delay / pause loading of data to avoid - # overloading of CCU / Homegear - time.sleep(self.hass.data[DATA_DELAY]) self._load_data_from_hm() # Link events from pyhomematic diff --git a/homeassistant/components/image_processing/dlib_face_detect.py b/homeassistant/components/image_processing/dlib_face_detect.py index 1c999782ec78a..65705feb7f7d9 100644 --- a/homeassistant/components/image_processing/dlib_face_detect.py +++ b/homeassistant/components/image_processing/dlib_face_detect.py @@ -15,7 +15,7 @@ from homeassistant.components.image_processing.microsoft_face_identify import ( ImageProcessingFaceEntity) -REQUIREMENTS = ['face_recognition==0.2.0'] +REQUIREMENTS = ['face_recognition==1.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/image_processing/dlib_face_identify.py b/homeassistant/components/image_processing/dlib_face_identify.py index ec98d5bdcff84..22594aa254700 100644 --- a/homeassistant/components/image_processing/dlib_face_identify.py +++ b/homeassistant/components/image_processing/dlib_face_identify.py @@ -16,7 +16,7 @@ ImageProcessingFaceEntity) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['face_recognition==0.2.0'] +REQUIREMENTS = ['face_recognition==1.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/input_text.py b/homeassistant/components/input_text.py new file mode 100755 index 0000000000000..583181fe453ec --- /dev/null +++ b/homeassistant/components/input_text.py @@ -0,0 +1,191 @@ +""" +Component to offer a way to enter a value into a text box. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/input_text/ +""" +import asyncio +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME) +from homeassistant.loader import bind_hass +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import async_get_last_state + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'input_text' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +CONF_INITIAL = 'initial' +CONF_MIN = 'min' +CONF_MAX = 'max' + +ATTR_VALUE = 'value' +ATTR_MIN = 'min' +ATTR_MAX = 'max' +ATTR_PATTERN = 'pattern' + +SERVICE_SET_VALUE = 'set_value' + +SERVICE_SET_VALUE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_VALUE): cv.string, +}) + + +def _cv_input_text(cfg): + """Configure validation helper for input box (voluptuous).""" + minimum = cfg.get(CONF_MIN) + maximum = cfg.get(CONF_MAX) + if minimum > maximum: + raise vol.Invalid('Max len ({}) is not greater than min len ({})' + .format(minimum, maximum)) + state = cfg.get(CONF_INITIAL) + if state is not None and (len(state) < minimum or len(state) > maximum): + raise vol.Invalid('Initial value {} length not in range {}-{}' + .format(state, minimum, maximum)) + return cfg + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: vol.All({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MIN, default=0): vol.Coerce(int), + vol.Optional(CONF_MAX, default=100): vol.Coerce(int), + vol.Optional(CONF_INITIAL, ''): cv.string, + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(ATTR_PATTERN): cv.string, + }, _cv_input_text) + }) +}, required=True, extra=vol.ALLOW_EXTRA) + + +@bind_hass +def set_value(hass, entity_id, value): + """Set input_text to value.""" + hass.services.call(DOMAIN, SERVICE_SET_VALUE, { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: value, + }) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up an input text box.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + name = cfg.get(CONF_NAME) + minimum = cfg.get(CONF_MIN) + maximum = cfg.get(CONF_MAX) + initial = cfg.get(CONF_INITIAL) + icon = cfg.get(CONF_ICON) + unit = cfg.get(ATTR_UNIT_OF_MEASUREMENT) + pattern = cfg.get(ATTR_PATTERN) + + entities.append(InputText( + object_id, name, initial, minimum, maximum, icon, unit, + pattern)) + + if not entities: + return False + + @asyncio.coroutine + def async_set_value_service(call): + """Handle a calls to the input box services.""" + target_inputs = component.async_extract_from_service(call) + + tasks = [input_text.async_set_value(call.data[ATTR_VALUE]) + for input_text in target_inputs] + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + hass.services.async_register( + DOMAIN, SERVICE_SET_VALUE, async_set_value_service, + schema=SERVICE_SET_VALUE_SCHEMA) + + yield from component.async_add_entities(entities) + return True + + +class InputText(Entity): + """Represent a text box.""" + + def __init__(self, object_id, name, initial, minimum, maximum, icon, + unit, pattern): + """Initialize a text input.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._current_value = initial + self._minimum = minimum + self._maximum = maximum + self._icon = icon + self._unit = unit + self._pattern = pattern + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return the name of the text input entity.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the state of the component.""" + return self._current_value + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_MIN: self._minimum, + ATTR_MAX: self._maximum, + ATTR_PATTERN: self._pattern, + } + + @asyncio.coroutine + def async_added_to_hass(self): + """Run when entity about to be added to hass.""" + if self._current_value is not None: + return + + state = yield from async_get_last_state(self.hass, self.entity_id) + value = state and state.state + + # Check against None because value can be 0 + if value is not None and self._minimum <= len(value) <= self._maximum: + self._current_value = value + + @asyncio.coroutine + def async_set_value(self, value): + """Select new value.""" + if len(value) < self._minimum or len(value) > self._maximum: + _LOGGER.warning("Invalid value: %s (length range %s - %s)", + value, self._minimum, self._maximum) + return + self._current_value = value + yield from self.async_update_ha_state() diff --git a/homeassistant/components/insteon_plm.py b/homeassistant/components/insteon_plm.py index 7cadbd0dd7fd4..94b70e47cba81 100644 --- a/homeassistant/components/insteon_plm.py +++ b/homeassistant/components/insteon_plm.py @@ -79,8 +79,12 @@ def async_plm_new_device(device): # # Override the device default capabilities for a specific address # - plm.protocol.devices.add_override( - device['address'], 'capabilities', [device['platform']]) + if isinstance(device['platform'], list): + plm.protocol.devices.add_override( + device['address'], 'capabilities', device['platform']) + else: + plm.protocol.devices.add_override( + device['address'], 'capabilities', [device['platform']]) hass.data['insteon_plm'] = plm @@ -98,7 +102,7 @@ def common_attributes(entity): 'address': 'INSTEON Address', 'description': 'Description', 'model': 'Model', - 'cat': 'Cagegory', + 'cat': 'Category', 'subcat': 'Subcategory', 'firmware': 'Firmware', 'product_key': 'Product Key' diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index a834cc0a3e44a..7686eb7dc7de8 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType, Dict # noqa -REQUIREMENTS = ['PyISY==1.0.7'] +REQUIREMENTS = ['PyISY==1.0.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index 9530becb6cea1..a5015ff94546f 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -1,495 +1,255 @@ """ -Support for KNX components. -For more details about this component, please refer to the documentation at +Connects to KNX platform. + +For more details about this platform, please refer to the documentation at https://home-assistant.io/components/knx/ + """ import logging -import os +import asyncio import voluptuous as vol +from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT) -from homeassistant.helpers.entity import Entity -from homeassistant.config import load_yaml_config_file +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, \ + CONF_HOST, CONF_PORT +from homeassistant.helpers.script import Script + +DOMAIN = "knx" +DATA_KNX = "data_knx" +CONF_KNX_CONFIG = "config_file" -REQUIREMENTS = ['knxip==0.5'] +CONF_KNX_ROUTING = "routing" +CONF_KNX_TUNNELING = "tunneling" +CONF_KNX_LOCAL_IP = "local_ip" +CONF_KNX_FIRE_EVENT = "fire_event" +CONF_KNX_FIRE_EVENT_FILTER = "fire_event_filter" + +SERVICE_KNX_SEND = "send" +SERVICE_KNX_ATTR_ADDRESS = "address" +SERVICE_KNX_ATTR_PAYLOAD = "payload" + +ATTR_DISCOVER_DEVICES = 'devices' _LOGGER = logging.getLogger(__name__) -DEFAULT_HOST = '0.0.0.0' -DEFAULT_PORT = 3671 -DOMAIN = 'knx' +REQUIREMENTS = ['xknx==0.7.13'] -EVENT_KNX_FRAME_RECEIVED = 'knx_frame_received' -EVENT_KNX_FRAME_SEND = 'knx_frame_send' +TUNNELING_SCHEMA = vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Required(CONF_KNX_LOCAL_IP): cv.string, +}) -KNXTUNNEL = None -KNX_ADDRESS = "address" -KNX_DATA = "data" -KNX_GROUP_WRITE = "group_write" -CONF_LISTEN = "listen" +ROUTING_SCHEMA = vol.Schema({ + vol.Required(CONF_KNX_LOCAL_IP): cv.string, +}) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_LISTEN, default=[]): - vol.All(cv.ensure_list, [cv.string]), - }), + vol.Optional(CONF_KNX_CONFIG): cv.string, + vol.Exclusive(CONF_KNX_ROUTING, 'connection_type'): ROUTING_SCHEMA, + vol.Exclusive(CONF_KNX_TUNNELING, 'connection_type'): + TUNNELING_SCHEMA, + vol.Inclusive(CONF_KNX_FIRE_EVENT, 'fire_ev'): + cv.boolean, + vol.Inclusive(CONF_KNX_FIRE_EVENT_FILTER, 'fire_ev'): + vol.All( + cv.ensure_list, + [cv.string]) + }) }, extra=vol.ALLOW_EXTRA) -KNX_WRITE_SCHEMA = vol.Schema({ - vol.Required(KNX_ADDRESS): vol.All(cv.ensure_list, [cv.string]), - vol.Required(KNX_DATA): vol.All(cv.ensure_list, [cv.byte]) +SERVICE_KNX_SEND_SCHEMA = vol.Schema({ + vol.Required(SERVICE_KNX_ATTR_ADDRESS): cv.string, + vol.Required(SERVICE_KNX_ATTR_PAYLOAD): vol.Any( + cv.positive_int, [cv.positive_int]), }) -def setup(hass, config): - """Set up the connection to the KNX IP interface.""" - global KNXTUNNEL - - from knxip.ip import KNXIPTunnel - from knxip.core import KNXException, parse_group_address - - host = config[DOMAIN].get(CONF_HOST) - port = config[DOMAIN].get(CONF_PORT) - - if host == '0.0.0.0': - _LOGGER.debug("Will try to auto-detect KNX/IP gateway") - - KNXTUNNEL = KNXIPTunnel(host, port) +@asyncio.coroutine +def async_setup(hass, config): + """Set up knx component.""" + from xknx.exceptions import XKNXException try: - res = KNXTUNNEL.connect() - _LOGGER.debug("Res = %s", res) - if not res: - _LOGGER.error("Could not connect to KNX/IP interface %s", host) - return False - - except KNXException as ex: - _LOGGER.exception("Can't connect to KNX/IP interface: %s", ex) - KNXTUNNEL = None - return False - - _LOGGER.info("KNX IP tunnel to %s:%i established", host, port) - - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) + hass.data[DATA_KNX] = KNXModule(hass, config) + yield from hass.data[DATA_KNX].start() - def received_knx_event(address, data): - """Process received KNX message.""" - if len(data) == 1: - data = data[0] - hass.bus.fire('knx_event', { - 'address': address, - 'data': data - }) + except XKNXException as ex: + _LOGGER.exception("Can't connect to KNX interface: %s", ex) + return False - for listen in config[DOMAIN].get(CONF_LISTEN): - _LOGGER.debug("Registering listener for %s", listen) - try: - KNXTUNNEL.register_listener(parse_group_address(listen), - received_knx_event) - except KNXException as knxexception: - _LOGGER.error("Can't register KNX listener for address %s (%s)", - listen, knxexception) - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_tunnel) - - # Listen to KNX events and send them to the bus - def handle_group_write(call): - """Bridge knx_frame_send events to the KNX bus.""" - # parameters are pre-validated using KNX_WRITE_SCHEMA - addrlist = call.data.get("address") - knxdata = call.data.get("data") - - knxaddrlist = [] - for addr in addrlist: - try: - _LOGGER.debug("Found %s", addr) - knxaddr = int(addr) - except ValueError: - knxaddr = None - - if knxaddr is None: - try: - knxaddr = parse_group_address(addr) - except KNXException: - _LOGGER.error("KNX address format incorrect: %s", addr) - - knxaddrlist.append(knxaddr) - - for addr in knxaddrlist: - KNXTUNNEL.group_write(addr, knxdata) - - # Listen for when knx_frame_send event is fired - hass.services.register(DOMAIN, - KNX_GROUP_WRITE, - handle_group_write, - descriptions[DOMAIN][KNX_GROUP_WRITE], - schema=KNX_WRITE_SCHEMA) + for component, discovery_type in ( + ('switch', 'Switch'), + ('climate', 'Climate'), + ('cover', 'Cover'), + ('light', 'Light'), + ('sensor', 'Sensor'), + ('binary_sensor', 'BinarySensor'), + ('notify', 'Notification')): + found_devices = _get_devices(hass, discovery_type) + hass.async_add_job( + discovery.async_load_platform(hass, component, DOMAIN, { + ATTR_DISCOVER_DEVICES: found_devices + }, config)) + + hass.services.async_register( + DOMAIN, SERVICE_KNX_SEND, + hass.data[DATA_KNX].service_send_to_knx_bus, + schema=SERVICE_KNX_SEND_SCHEMA) return True -def close_tunnel(_data): - """Close the NKX tunnel connection on shutdown.""" - global KNXTUNNEL - - KNXTUNNEL.disconnect() - KNXTUNNEL = None +def _get_devices(hass, discovery_type): + return list( + map(lambda device: device.name, + filter( + lambda device: type(device).__name__ == discovery_type, + hass.data[DATA_KNX].xknx.devices))) -class KNXConfig(object): - """Handle the fetching of configuration from the config file.""" - - def __init__(self, config): - """Initialize the configuration.""" - from knxip.core import parse_group_address - - self.config = config - self.should_poll = config.get('poll', True) - if config.get('address'): - self._address = parse_group_address(config.get('address')) - else: - self._address = None - if self.config.get('state_address'): - self._state_address = parse_group_address( - self.config.get('state_address')) - else: - self._state_address = None - - @property - def name(self): - """Return the name given to the entity.""" - return self.config['name'] - - @property - def address(self): - """Return the address of the device as an integer value. - - 3 types of addresses are supported: - integer - 0-65535 - 2 level - a/b - 3 level - a/b/c - """ - return self._address - - @property - def state_address(self): - """Return the group address the device sends its current state to. - - Some KNX devices can send the current state to a seperate - group address. This makes send e.g. when an actuator can - be switched but also have a timer functionality. - """ - return self._state_address - - -class KNXGroupAddress(Entity): - """Representation of devices connected to a KNX group address.""" +class KNXModule(object): + """Representation of KNX Object.""" def __init__(self, hass, config): - """Initialize the device.""" - self._config = config - self._state = False - self._data = None - _LOGGER.debug( - "Initalizing KNX group address for %s (%s)", - self.name, self.address - ) - - def handle_knx_message(addr, data): - """Handle an incoming KNX frame. - - Handle an incoming frame and update our status if it contains - information relating to this device. - """ - if (addr == self.state_address) or (addr == self.address): - self._state = data[0] - self.schedule_update_ha_state() - - KNXTUNNEL.register_listener(self.address, handle_knx_message) - if self.state_address: - KNXTUNNEL.register_listener(self.state_address, handle_knx_message) - - @property - def name(self): - """Return the entity's display name.""" - return self._config.name - - @property - def config(self): - """Return the entity's configuration.""" - return self._config - - @property - def should_poll(self): - """Return the state of the polling, if needed.""" - return self._config.should_poll - - @property - def is_on(self): - """Return True if the value is not 0 is on, else False.""" - return self._state != 0 - - @property - def address(self): - """Return the KNX group address.""" - return self._config.address - - @property - def state_address(self): - """Return the KNX group address.""" - return self._config.state_address - - @property - def cache(self): - """Return the name given to the entity.""" - return self._config.config.get('cache', True) - - def group_write(self, value): - """Write to the group address.""" - KNXTUNNEL.group_write(self.address, [value]) - - def update(self): - """Get the state from KNX bus or cache.""" - from knxip.core import KNXException - - try: - if self.state_address: - res = KNXTUNNEL.group_read( - self.state_address, use_cache=self.cache) - else: - res = KNXTUNNEL.group_read(self.address, use_cache=self.cache) - - if res: - self._state = res[0] - self._data = res - else: - _LOGGER.debug( - "%s: unable to read from KNX address: %s (None)", - self.name, self.address - ) - - except KNXException: - _LOGGER.exception( - "%s: unable to read from KNX address: %s", - self.name, self.address - ) - return False - - -class KNXMultiAddressDevice(Entity): - """Representation of devices connected to a multiple KNX group address. - - This is needed for devices like dimmers or shutter actuators as they have - to be controlled by multiple group addresses. - """ - - def __init__(self, hass, config, required, optional=None): - """Initialize the device. - - The namelist argument lists the required addresses. E.g. for a dimming - actuators, the namelist might look like: - onoff_address: 0/0/1 - brightness_address: 0/0/2 - """ - from knxip.core import parse_group_address, KNXException - - self.names = {} - self.values = {} - - self._config = config - self._state = False - self._data = None - _LOGGER.debug( - "%s: initalizing KNX multi address device", - self.name - ) - - settings = self._config.config - if config.address: - _LOGGER.debug( - "%s: base address: address=%s", - self.name, settings.get('address') - ) - self.names[config.address] = 'base' - if config.state_address: - _LOGGER.debug( - "%s, state address: state_address=%s", - self.name, settings.get('state_address') - ) - self.names[config.state_address] = 'state' - - # parse required addresses - for name in required: - paramname = '{}{}'.format(name, '_address') - addr = settings.get(paramname) - if addr is None: - _LOGGER.error( - "%s: Required KNX group address %s missing", - self.name, paramname - ) - raise KNXException( - "%s: Group address for {} missing in " - "configuration for {}".format( - self.name, paramname - ) - ) - _LOGGER.debug( - "%s: (required parameter) %s=%s", - self.name, paramname, addr - ) - addr = parse_group_address(addr) - self.names[addr] = name - - # parse optional addresses - for name in optional: - paramname = '{}{}'.format(name, '_address') - addr = settings.get(paramname) - _LOGGER.debug( - "%s: (optional parameter) %s=%s", - self.name, paramname, addr - ) - if addr: - try: - addr = parse_group_address(addr) - except KNXException: - _LOGGER.exception( - "%s: cannot parse group address %s", - self.name, addr - ) - self.names[addr] = name - - @property - def name(self): - """Return the entity's display name.""" - return self._config.name - - @property - def config(self): - """Return the entity's configuration.""" - return self._config - - @property - def should_poll(self): - """Return the state of the polling, if needed.""" - return self._config.should_poll - - @property - def cache(self): - """Return the name given to the entity.""" - return self._config.config.get('cache', True) - - def has_attribute(self, name): - """Check if the attribute with the given name is defined. - - This is mostly important for optional addresses. - """ - for attributename in self.names.values(): - if attributename == name: - return True + """Initialization of KNXModule.""" + self.hass = hass + self.config = config + self.initialized = False + self.init_xknx() + self.register_callbacks() + + def init_xknx(self): + """Initialization of KNX object.""" + from xknx import XKNX + self.xknx = XKNX( + config=self.config_file(), + loop=self.hass.loop) + + @asyncio.coroutine + def start(self): + """Start KNX object. Connect to tunneling or Routing device.""" + connection_config = self.connection_config() + yield from self.xknx.start( + state_updater=True, + connection_config=connection_config) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) + self.initialized = True + + @asyncio.coroutine + def stop(self, event): + """Stop KNX object. Disconnect from tunneling or Routing device.""" + yield from self.xknx.stop() + + def config_file(self): + """Resolve and return the full path of xknx.yaml if configured.""" + config_file = self.config[DOMAIN].get(CONF_KNX_CONFIG) + if not config_file: + return None + if not config_file.startswith("/"): + return self.hass.config.path(config_file) + return config_file + + def connection_config(self): + """Return the connection_config.""" + if CONF_KNX_TUNNELING in self.config[DOMAIN]: + return self.connection_config_tunneling() + elif CONF_KNX_ROUTING in self.config[DOMAIN]: + return self.connection_config_routing() + return self.connection_config_auto() + + def connection_config_routing(self): + """Return the connection_config if routing is configured.""" + from xknx.io import ConnectionConfig, ConnectionType + local_ip = \ + self.config[DOMAIN][CONF_KNX_ROUTING].get(CONF_KNX_LOCAL_IP) + return ConnectionConfig( + connection_type=ConnectionType.ROUTING, + local_ip=local_ip) + + def connection_config_tunneling(self): + """Return the connection_config if tunneling is configured.""" + from xknx.io import ConnectionConfig, ConnectionType, \ + DEFAULT_MCAST_PORT + gateway_ip = \ + self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_HOST) + gateway_port = \ + self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_PORT) + local_ip = \ + self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_KNX_LOCAL_IP) + if gateway_port is None: + gateway_port = DEFAULT_MCAST_PORT + return ConnectionConfig( + connection_type=ConnectionType.TUNNELING, + gateway_ip=gateway_ip, + gateway_port=gateway_port, + local_ip=local_ip) + + def connection_config_auto(self): + """Return the connection_config if auto is configured.""" + # pylint: disable=no-self-use + from xknx.io import ConnectionConfig + return ConnectionConfig() + + def register_callbacks(self): + """Register callbacks within XKNX object.""" + if CONF_KNX_FIRE_EVENT in self.config[DOMAIN] and \ + self.config[DOMAIN][CONF_KNX_FIRE_EVENT]: + from xknx.knx import AddressFilter + address_filters = list(map( + AddressFilter, + self.config[DOMAIN][CONF_KNX_FIRE_EVENT_FILTER])) + self.xknx.telegram_queue.register_telegram_received_cb( + self.telegram_received_cb, address_filters) + + @asyncio.coroutine + def telegram_received_cb(self, telegram): + """Callback invoked after a KNX telegram was received.""" + self.hass.bus.fire('knx_event', { + 'address': telegram.group_address.str(), + 'data': telegram.payload.value + }) + # False signals XKNX to proceed with processing telegrams. return False - def set_percentage(self, name, percentage): - """Set a percentage in knx for a given attribute. - - DPT_Scaling / DPT 5.001 is a single byte scaled percentage - """ - percentage = abs(percentage) # only accept positive values - scaled_value = percentage * 255 / 100 - value = min(255, scaled_value) - return self.set_int_value(name, value) - - def get_percentage(self, name): - """Get a percentage from knx for a given attribute. - - DPT_Scaling / DPT 5.001 is a single byte scaled percentage - """ - value = self.get_int_value(name) - percentage = round(value * 100 / 255) - return percentage - - def set_int_value(self, name, value, num_bytes=1): - """Set an integer value for a given attribute.""" - # KNX packets are big endian - value = round(value) # only accept integers - b_value = value.to_bytes(num_bytes, byteorder='big') - return self.set_value(name, list(b_value)) - - def get_int_value(self, name): - """Get an integer value for a given attribute.""" - # KNX packets are big endian - summed_value = 0 - raw_value = self.value(name) - try: - # convert raw value in bytes - for val in raw_value: - summed_value *= 256 - summed_value += val - except TypeError: - # pknx returns a non-iterable type for unsuccessful reads - pass - - return summed_value - - def value(self, name): - """Return the value to a given named attribute.""" - from knxip.core import KNXException - - addr = None - for attributeaddress, attributename in self.names.items(): - if attributename == name: - addr = attributeaddress - - if addr is None: - _LOGGER.error("%s: attribute '%s' undefined", - self.name, name) - _LOGGER.debug( - "%s: defined attributes: %s", - self.name, str(self.names) - ) - return False - - try: - res = KNXTUNNEL.group_read(addr, use_cache=self.cache) - except KNXException: - _LOGGER.exception( - "%s: unable to read from KNX address: %s", - self.name, addr - ) - return False - - return res - - def set_value(self, name, value): - """Set the value of a given named attribute.""" - from knxip.core import KNXException - - addr = None - for attributeaddress, attributename in self.names.items(): - if attributename == name: - addr = attributeaddress - - if addr is None: - _LOGGER.error("%s: attribute '%s' undefined", - self.name, name) - _LOGGER.debug( - "%s: defined attributes: %s", - self.name, str(self.names) - ) - return False - - try: - KNXTUNNEL.group_write(addr, value) - except KNXException: - _LOGGER.exception( - "%s: unable to write to KNX address: %s", - self.name, addr - ) - return False - - return True + @asyncio.coroutine + def service_send_to_knx_bus(self, call): + """Service for sending an arbitray KNX message to the KNX bus.""" + from xknx.knx import Telegram, Address, DPTBinary, DPTArray + attr_payload = call.data.get(SERVICE_KNX_ATTR_PAYLOAD) + attr_address = call.data.get(SERVICE_KNX_ATTR_ADDRESS) + + def calculate_payload(attr_payload): + """Calculate payload depending on type of attribute.""" + if isinstance(attr_payload, int): + return DPTBinary(attr_payload) + return DPTArray(attr_payload) + payload = calculate_payload(attr_payload) + address = Address(attr_address) + + telegram = Telegram() + telegram.payload = payload + telegram.group_address = address + yield from self.xknx.telegrams.put(telegram) + + +class KNXAutomation(): + """Wrapper around xknx.devices.ActionCallback object..""" + + def __init__(self, hass, device, hook, action, counter=1): + """Initialize Automation class.""" + self.hass = hass + self.device = device + script_name = "{} turn ON script".format(device.get_name()) + self.script = Script(hass, action, script_name) + + import xknx + self.action = xknx.devices.ActionCallback( + hass.data[DATA_KNX].xknx, + self.script.async_run, + hook=hook, + counter=counter) + device.actions.append(self.action) diff --git a/homeassistant/components/light/decora_wifi.py b/homeassistant/components/light/decora_wifi.py index 3a4b892cbfe7d..971ad21e84baa 100644 --- a/homeassistant/components/light/decora_wifi.py +++ b/homeassistant/components/light/decora_wifi.py @@ -116,8 +116,8 @@ def turn_on(self, **kwargs): attribs = {'power': 'ON'} if ATTR_BRIGHTNESS in kwargs: - min_level = self._switch.get('minLevel', 0) - max_level = self._switch.get('maxLevel', 100) + min_level = self._switch.data.get('minLevel', 0) + max_level = self._switch.data.get('maxLevel', 100) brightness = int(kwargs[ATTR_BRIGHTNESS] * max_level / 255) brightness = max(brightness, min_level) attribs['brightness'] = brightness diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index 21012f81658a1..209c3ab772411 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -122,7 +122,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if ipaddr in light_ips: continue device['name'] = '{} {}'.format(device['id'], ipaddr) - device[ATTR_MODE] = 'rgbw' + device[ATTR_MODE] = MODE_RGBW device[CONF_PROTOCOL] = None light = FluxLight(device) lights.append(light) @@ -216,9 +216,9 @@ def turn_on(self, **kwargs): elif rgb is not None: self._bulb.setRgb(*tuple(rgb)) elif brightness is not None: - if self._mode == 'rgbw': + if self._mode == MODE_RGBW: self._bulb.setWarmWhite255(brightness) - elif self._mode == 'rgb': + elif self._mode == MODE_RGB: (red, green, blue) = self._bulb.getRgb() self._bulb.setRgb(red, green, blue, brightness=brightness) elif effect == EFFECT_RANDOM: diff --git a/homeassistant/components/light/homematic.py b/homeassistant/components/light/homematic.py index 60865dd223e81..807c19fffdb32 100644 --- a/homeassistant/components/light/homematic.py +++ b/homeassistant/components/light/homematic.py @@ -24,8 +24,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMLight(hass, conf) - new_device.link_homematic() + new_device = HMLight(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index cdbea7d2194ea..79d80d2b8a039 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -23,7 +23,6 @@ SUPPORT_XY_COLOR, Light, PLATFORM_SCHEMA) from homeassistant.config import load_yaml_config_file from homeassistant.const import (CONF_FILENAME, CONF_HOST, DEVICE_DEFAULT_NAME) -from homeassistant.loader import get_component from homeassistant.components.emulated_hue import ATTR_EMULATED_HUE import homeassistant.helpers.config_validation as cv @@ -84,6 +83,7 @@ }) ATTR_IS_HUE_GROUP = "is_hue_group" +GROUP_NAME_ALL_HUE_LIGHTS = "All Hue Lights" def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): @@ -164,9 +164,7 @@ def setup_bridge(host, hass, add_devices, filename, allow_unreachable, # If we came here and configuring this host, mark as done if host in _CONFIGURING: request_id = _CONFIGURING.pop(host) - - configurator = get_component('configurator') - + configurator = hass.components.configurator configurator.request_done(request_id) lights = {} @@ -206,6 +204,21 @@ def update_lights(): _LOGGER.error("Got unexpected result from Hue API") return + if not skip_groups: + # Group ID 0 is a special group in the hub for all lights, but it + # is not returned by get_api() so explicity get it and include it. + # See https://developers.meethue.com/documentation/ + # groups-api#21_get_all_groups + _LOGGER.debug("Getting group 0 from bridge") + all_lights = bridge.get_group(0) + if not isinstance(all_lights, dict): + _LOGGER.error("Got unexpected result from Hue API for group 0") + return + # Hue hub returns name of group 0 as "Group 0", so rename + # for ease of use in HA. + all_lights['name'] = GROUP_NAME_ALL_HUE_LIGHTS + api_groups["0"] = all_lights + new_lights = [] api_name = api.get('config').get('name') @@ -268,7 +281,7 @@ def request_configuration(host, hass, add_devices, filename, allow_unreachable, allow_in_emulated_hue, allow_hue_groups): """Request configuration steps from the user.""" - configurator = get_component('configurator') + configurator = hass.components.configurator # We got an error if this method is called while we are configuring if host in _CONFIGURING: @@ -284,7 +297,7 @@ def hue_configuration_callback(data): allow_in_emulated_hue, allow_hue_groups) _CONFIGURING[host] = configurator.request_config( - hass, "Philips Hue", hue_configuration_callback, + "Philips Hue", hue_configuration_callback, description=("Press the button on the bridge to register Philips Hue " "with Home Assistant."), entity_picture="/static/images/logo_philips_hue.png", @@ -384,7 +397,6 @@ def turn_on(self, **kwargs): hue, sat = color_util.color_xy_to_hs(*kwargs[ATTR_XY_COLOR]) command['hue'] = hue command['sat'] = sat - command['bri'] = self.info['bri'] else: command['xy'] = kwargs[ATTR_XY_COLOR] elif ATTR_RGB_COLOR in kwargs: @@ -399,14 +411,13 @@ def turn_on(self, **kwargs): *(int(val) for val in kwargs[ATTR_RGB_COLOR])) command['xy'] = xyb[0], xyb[1] command['bri'] = xyb[2] + elif ATTR_COLOR_TEMP in kwargs: + temp = kwargs[ATTR_COLOR_TEMP] + command['ct'] = max(self.min_mireds, min(temp, self.max_mireds)) if ATTR_BRIGHTNESS in kwargs: command['bri'] = kwargs[ATTR_BRIGHTNESS] - if ATTR_COLOR_TEMP in kwargs: - temp = kwargs[ATTR_COLOR_TEMP] - command['ct'] = max(self.min_mireds, min(temp, self.max_mireds)) - flash = kwargs.get(ATTR_FLASH) if flash == FLASH_LONG: @@ -425,9 +436,9 @@ def turn_on(self, **kwargs): elif effect == EFFECT_RANDOM: command['hue'] = random.randrange(0, 65535) command['sat'] = random.randrange(150, 254) - elif self.bridge_type == 'hue': - if self.info.get('manufacturername') != "OSRAM": - command['effect'] = 'none' + elif (self.bridge_type == 'hue' and + self.info.get('manufacturername') == 'Philips'): + command['effect'] = 'none' self._command_func(self.light_id, command) diff --git a/homeassistant/components/light/insteon_local.py b/homeassistant/components/light/insteon_local.py index e5b99ca1cb261..ebd6ab92d0fd3 100644 --- a/homeassistant/components/light/insteon_local.py +++ b/homeassistant/components/light/insteon_local.py @@ -11,7 +11,6 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) -from homeassistant.loader import get_component import homeassistant.util as util _CONFIGURING = {} @@ -54,7 +53,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): def request_configuration(device_id, insteonhub, model, hass, add_devices_callback): """Request configuration steps from the user.""" - configurator = get_component('configurator') + configurator = hass.components.configurator # We got an error if this method is called while we are configuring if device_id in _CONFIGURING: @@ -69,7 +68,7 @@ def insteon_light_config_callback(data): add_devices_callback) _CONFIGURING[device_id] = configurator.request_config( - hass, 'Insteon ' + model + ' addr: ' + device_id, + 'Insteon ' + model + ' addr: ' + device_id, insteon_light_config_callback, description=('Enter a name for ' + model + ' addr: ' + device_id), entity_picture='/static/images/config_insteon.png', @@ -82,7 +81,7 @@ def setup_light(device_id, name, insteonhub, hass, add_devices_callback): """Set up the light.""" if device_id in _CONFIGURING: request_id = _CONFIGURING.pop(device_id) - configurator = get_component('configurator') + configurator = hass.components.configurator configurator.request_done(request_id) _LOGGER.debug("Device configuration done") diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index d89d45e99a747..62261944febb6 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -1,17 +1,17 @@ """ -Support KNX Lighting actuators. +Support for KNX/IP lights. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/Light.knx/ +https://home-assistant.io/components/light.knx/ """ -import logging +import asyncio import voluptuous as vol -from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice) -from homeassistant.components.light import (Light, PLATFORM_SCHEMA, - SUPPORT_BRIGHTNESS, - ATTR_BRIGHTNESS) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.light import PLATFORM_SCHEMA, Light, \ + SUPPORT_BRIGHTNESS, ATTR_BRIGHTNESS from homeassistant.const import CONF_NAME +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv CONF_ADDRESS = 'address' @@ -19,8 +19,6 @@ CONF_BRIGHTNESS_ADDRESS = 'brightness_address' CONF_BRIGHTNESS_STATE_ADDRESS = 'brightness_state_address' -_LOGGER = logging.getLogger(__name__) - DEFAULT_NAME = 'KNX Light' DEPENDENCIES = ['knx'] @@ -33,84 +31,136 @@ }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX light platform.""" - add_devices([KNXLight(hass, KNXConfig(config))]) - - -class KNXLight(KNXMultiAddressDevice, Light): - """Representation of a KNX Light device.""" - - def __init__(self, hass, config): - """Initialize the cover.""" - KNXMultiAddressDevice.__init__( - self, hass, config, - [], # required - optional=['state', 'brightness', 'brightness_state'] - ) - self._hass = hass - self._supported_features = 0 - - if CONF_BRIGHTNESS_ADDRESS in config.config: - _LOGGER.debug("%s is dimmable", self.name) - self._supported_features = self._supported_features | \ - SUPPORT_BRIGHTNESS - self._brightness = None - - def turn_on(self, **kwargs): - """Turn the switch on. - - This sends a value 1 to the group address of the device - """ - _LOGGER.debug("%s: turn on", self.name) - self.set_value('base', [1]) - self._state = 1 - - if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] - _LOGGER.debug("turn_on requested brightness for light: %s is: %s ", - self.name, self._brightness) - assert self._brightness <= 255 - self.set_value("brightness", [self._brightness]) - - if not self.should_poll: - self.schedule_update_ha_state() - - def turn_off(self, **kwargs): - """Turn the switch off. - - This sends a value 1 to the group address of the device - """ - _LOGGER.debug("%s: turn off", self.name) - self.set_value('base', [0]) - self._state = 0 - if not self.should_poll: - self.schedule_update_ha_state() +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up light(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True + + +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up lights for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXLight(hass, device)) + add_devices(entities) + + +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up light for KNX platform configured within plattform.""" + import xknx + light = xknx.devices.Light( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address_switch=config.get(CONF_ADDRESS), + group_address_switch_state=config.get(CONF_STATE_ADDRESS), + group_address_brightness=config.get(CONF_BRIGHTNESS_ADDRESS), + group_address_brightness_state=config.get( + CONF_BRIGHTNESS_STATE_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(light) + add_devices([KNXLight(hass, light)]) + + +class KNXLight(Light): + """Representation of a KNX light.""" + + def __init__(self, hass, device): + """Initialization of KNXLight.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name + + @property + def should_poll(self): + """No polling needed within KNX.""" + return False + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self.device.brightness \ + if self.device.supports_dimming else \ + None + + @property + def xy_color(self): + """Return the XY color value [float, float].""" + return None + + @property + def rgb_color(self): + """Return the RBG color value.""" + return None + + @property + def color_temp(self): + """Return the CT color temperature.""" + return None + + @property + def white_value(self): + """Return the white value of this light between 0..255.""" + return None + + @property + def effect_list(self): + """Return the list of supported effects.""" + return None + + @property + def effect(self): + """Return the current effect.""" + return None @property def is_on(self): - """Return True if the value is not 0 is on, else False.""" - return self._state != 0 + """Return true if light is on.""" + return self.device.state @property def supported_features(self): """Flag supported features.""" - return self._supported_features - - def update(self): - """Update device state.""" - super().update() - if self.has_attribute('brightness_state'): - value = self.value('brightness_state') - if value is not None: - self._brightness = int.from_bytes(value, byteorder='little') - _LOGGER.debug("%s: brightness = %d", - self.name, self._brightness) - - if self.has_attribute('state'): - self._state = self.value("state")[0] - _LOGGER.debug("%s: state = %d", self.name, self._state) - - def should_poll(self): - """No polling needed for a KNX light.""" - return False + flags = 0 + if self.device.supports_dimming: + flags |= SUPPORT_BRIGHTNESS + return flags + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs and self.device.supports_dimming: + yield from self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS])) + else: + yield from self.device.set_on() + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the light off.""" + yield from self.device.set_off() diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index a7301c24a8833..6b57a1c514631 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -325,29 +325,33 @@ def async_register(self, device): entity = self.entities[device.mac_addr] entity.registered = True _LOGGER.debug("%s register AGAIN", entity.who) - yield from entity.async_update() - yield from entity.async_update_ha_state() + yield from entity.update_hass() else: _LOGGER.debug("%s register NEW", device.ip_addr) - device.timeout = MESSAGE_TIMEOUT - device.retry_count = MESSAGE_RETRIES - device.unregister_timeout = UNAVAILABLE_GRACE + # Read initial state ack = AwaitAioLIFX().wait - yield from ack(device.get_version) - yield from ack(device.get_color) - - if lifxwhite(device): - entity = LIFXWhite(device, self.effects_conductor) - elif lifxmultizone(device): - yield from ack(partial(device.get_color_zones, start_index=0)) - entity = LIFXStrip(device, self.effects_conductor) + version_resp = yield from ack(device.get_version) + if version_resp: + color_resp = yield from ack(device.get_color) + + if version_resp is None or color_resp is None: + _LOGGER.error("Failed to initialize %s", device.ip_addr) else: - entity = LIFXColor(device, self.effects_conductor) + device.timeout = MESSAGE_TIMEOUT + device.retry_count = MESSAGE_RETRIES + device.unregister_timeout = UNAVAILABLE_GRACE + + if lifxwhite(device): + entity = LIFXWhite(device, self.effects_conductor) + elif lifxmultizone(device): + entity = LIFXStrip(device, self.effects_conductor) + else: + entity = LIFXColor(device, self.effects_conductor) - _LOGGER.debug("%s register READY", entity.who) - self.entities[device.mac_addr] = entity - self.async_add_devices([entity]) + _LOGGER.debug("%s register READY", entity.who) + self.entities[device.mac_addr] = entity + self.async_add_devices([entity], True) @callback def unregister(self, device): @@ -674,9 +678,14 @@ def async_update(self): @asyncio.coroutine def update_color_zones(self): """Get updated color information for each zone.""" - ack = AwaitAioLIFX().wait - bulb = self.device - - # Each get_color_zones returns the next 8 zones - for zone in range(0, len(bulb.color_zones), 8): - yield from ack(partial(bulb.get_color_zones, start_index=zone)) + zone = 0 + top = 1 + while self.available and zone < top: + # Each get_color_zones can update 8 zones at once + resp = yield from AwaitAioLIFX().wait(partial( + self.device.get_color_zones, + start_index=zone, + end_index=zone+7)) + if resp: + zone += 8 + top = resp.count diff --git a/homeassistant/components/light/lutron_caseta.py b/homeassistant/components/light/lutron_caseta.py index 8e4e9d7450ec3..c11b3da6f750f 100644 --- a/homeassistant/components/light/lutron_caseta.py +++ b/homeassistant/components/light/lutron_caseta.py @@ -7,7 +7,7 @@ import logging from homeassistant.components.light import ( - ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, DOMAIN) from homeassistant.components.light.lutron import ( to_hass_level, to_lutron_level) from homeassistant.components.lutron_caseta import ( @@ -23,7 +23,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Lutron Caseta lights.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] - light_devices = bridge.get_devices_by_types(["WallDimmer", "PlugInDimmer"]) + light_devices = bridge.get_devices_by_domain(DOMAIN) for light_device in light_devices: dev = LutronCasetaLight(light_device, bridge) devs.append(dev) diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt.py index 038cacd300ee0..ac72a7052f11b 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt.py @@ -39,6 +39,7 @@ CONF_EFFECT_LIST = 'effect_list' CONF_EFFECT_STATE_TOPIC = 'effect_state_topic' CONF_EFFECT_VALUE_TEMPLATE = 'effect_value_template' +CONF_RGB_COMMAND_TEMPLATE = 'rgb_command_template' CONF_RGB_COMMAND_TOPIC = 'rgb_command_topic' CONF_RGB_STATE_TOPIC = 'rgb_state_topic' CONF_RGB_VALUE_TEMPLATE = 'rgb_value_template' @@ -75,6 +76,7 @@ vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, + vol.Optional(CONF_RGB_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_RGB_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_RGB_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_RGB_VALUE_TEMPLATE): cv.template, @@ -125,6 +127,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): CONF_COLOR_TEMP: config.get(CONF_COLOR_TEMP_VALUE_TEMPLATE), CONF_EFFECT: config.get(CONF_EFFECT_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), CONF_WHITE_VALUE: config.get(CONF_WHITE_VALUE_TEMPLATE), CONF_XY: config.get(CONF_XY_VALUE_TEMPLATE), @@ -397,10 +400,17 @@ def async_turn_on(self, **kwargs): if ATTR_RGB_COLOR in kwargs and \ self._topic[CONF_RGB_COMMAND_TOPIC] is not None: + tpl = self._templates[CONF_RGB_COMMAND_TEMPLATE] + if tpl: + colors = {'red', 'green', 'blue'} + variables = {key: val for key, val in + zip(colors, kwargs[ATTR_RGB_COLOR])} + rgb_color_str = tpl.async_render(variables) + else: + rgb_color_str = '{},{},{}'.format(*kwargs[ATTR_RGB_COLOR]) mqtt.async_publish( self.hass, self._topic[CONF_RGB_COMMAND_TOPIC], - '{},{},{}'.format(*kwargs[ATTR_RGB_COLOR]), self._qos, - self._retain) + rgb_color_str, self._qos, self._retain) if self._optimistic_rgb: self._rgb = kwargs[ATTR_RGB_COLOR] diff --git a/homeassistant/components/light/mysensors.py b/homeassistant/components/light/mysensors.py index 203119e5e51e5..c41f480c67e19 100644 --- a/homeassistant/components/light/mysensors.py +++ b/homeassistant/components/light/mysensors.py @@ -4,64 +4,35 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.mysensors/ """ -import logging - from homeassistant.components import mysensors from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_WHITE_VALUE, + ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_WHITE_VALUE, DOMAIN, SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, SUPPORT_WHITE_VALUE, Light) from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.util.color import rgb_hex_to_rgb_list -_LOGGER = logging.getLogger(__name__) -ATTR_VALUE = 'value' -ATTR_VALUE_TYPE = 'value_type' - SUPPORT_MYSENSORS = (SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR | SUPPORT_WHITE_VALUE) def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the MySensors platform for lights.""" - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - # Define the S_TYPES and V_TYPES that the platform should handle as - # states. Map them in a dict of lists. - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_DIMMER: [set_req.V_DIMMER], - } - device_class_map = { - pres.S_DIMMER: MySensorsLightDimmer, - } - if float(gateway.protocol_version) >= 1.5: - map_sv_types.update({ - pres.S_RGB_LIGHT: [set_req.V_RGB], - pres.S_RGBW_LIGHT: [set_req.V_RGBW], - }) - map_sv_types[pres.S_DIMMER].append(set_req.V_PERCENTAGE) - device_class_map.update({ - pres.S_RGB_LIGHT: MySensorsLightRGB, - pres.S_RGBW_LIGHT: MySensorsLightRGBW, - }) - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, device_class_map, add_devices)) - - -class MySensorsLight(mysensors.MySensorsDeviceEntity, Light): + """Setup the mysensors platform for lights.""" + device_class_map = { + 'S_DIMMER': MySensorsLightDimmer, + 'S_RGB_LIGHT': MySensorsLightRGB, + 'S_RGBW_LIGHT': MySensorsLightRGBW, + } + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, device_class_map, + add_devices=add_devices) + + +class MySensorsLight(mysensors.MySensorsEntity, Light): """Representation of a MySensors Light child node.""" def __init__(self, *args): """Initialize a MySensors Light.""" - mysensors.MySensorsDeviceEntity.__init__(self, *args) + super().__init__(*args) self._state = None self._brightness = None self._rgb = None @@ -101,7 +72,7 @@ def _turn_on_light(self): """Turn on light child device.""" set_req = self.gateway.const.SetReq - if self._state or set_req.V_LIGHT not in self._values: + if self._state: return self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_LIGHT, 1) @@ -110,7 +81,6 @@ def _turn_on_light(self): # optimistically assume that light has changed state self._state = True self._values[set_req.V_LIGHT] = STATE_ON - self.schedule_update_ha_state() def _turn_on_dimmer(self, **kwargs): """Turn on dimmer child device.""" @@ -130,7 +100,6 @@ def _turn_on_dimmer(self, **kwargs): # optimistically assume that light has changed state self._brightness = brightness self._values[set_req.V_DIMMER] = percent - self.schedule_update_ha_state() def _turn_on_rgb_and_w(self, hex_template, **kwargs): """Turn on RGB or RGBW child device.""" @@ -144,16 +113,11 @@ def _turn_on_rgb_and_w(self, hex_template, **kwargs): return if new_rgb is not None: rgb = list(new_rgb) - if rgb is None: - return if hex_template == '%02x%02x%02x%02x': if new_white is not None: rgb.append(new_white) - elif white is not None: - rgb.append(white) else: - _LOGGER.error("White value is not updated for RGBW light") - return + rgb.append(white) hex_color = hex_template % tuple(rgb) if len(rgb) > 3: white = rgb.pop() @@ -164,104 +128,40 @@ def _turn_on_rgb_and_w(self, hex_template, **kwargs): # optimistically assume that light has changed state self._rgb = rgb self._white = white - if hex_color: - self._values[self.value_type] = hex_color - self.schedule_update_ha_state() + self._values[self.value_type] = hex_color - def _turn_off_light(self, value_type=None, value=None): - """Turn off light child device.""" - set_req = self.gateway.const.SetReq - value_type = ( - set_req.V_LIGHT - if set_req.V_LIGHT in self._values else value_type) - value = 0 if set_req.V_LIGHT in self._values else value - return {ATTR_VALUE_TYPE: value_type, ATTR_VALUE: value} - - def _turn_off_dimmer(self, value_type=None, value=None): - """Turn off dimmer child device.""" - set_req = self.gateway.const.SetReq - value_type = ( - set_req.V_DIMMER - if set_req.V_DIMMER in self._values else value_type) - value = 0 if set_req.V_DIMMER in self._values else value - return {ATTR_VALUE_TYPE: value_type, ATTR_VALUE: value} - - def _turn_off_rgb_or_w(self, value_type=None, value=None): - """Turn off RGB or RGBW child device.""" - if float(self.gateway.protocol_version) >= 1.5: - set_req = self.gateway.const.SetReq - if self.value_type == set_req.V_RGB: - value = '000000' - elif self.value_type == set_req.V_RGBW: - value = '00000000' - return {ATTR_VALUE_TYPE: self.value_type, ATTR_VALUE: value} - - def _turn_off_main(self, value_type=None, value=None): + def turn_off(self): """Turn the device off.""" - set_req = self.gateway.const.SetReq - if value_type is None or value is None: - _LOGGER.warning( - "%s: value_type %s, value = %s, None is not valid argument " - "when setting child value", self._name, value_type, value) - return + value_type = self.gateway.const.SetReq.V_LIGHT self.gateway.set_child_value( - self.node_id, self.child_id, value_type, value) + self.node_id, self.child_id, value_type, 0) if self.gateway.optimistic: # optimistically assume that light has changed state self._state = False - self._values[value_type] = ( - STATE_OFF if set_req.V_LIGHT in self._values else value) + self._values[value_type] = STATE_OFF self.schedule_update_ha_state() def _update_light(self): """Update the controller with values from light child.""" value_type = self.gateway.const.SetReq.V_LIGHT - if value_type in self._values: - self._values[value_type] = ( - STATE_ON if int(self._values[value_type]) == 1 else STATE_OFF) - self._state = self._values[value_type] == STATE_ON + self._state = self._values[value_type] == STATE_ON def _update_dimmer(self): """Update the controller with values from dimmer child.""" - set_req = self.gateway.const.SetReq - value_type = set_req.V_DIMMER + value_type = self.gateway.const.SetReq.V_DIMMER if value_type in self._values: self._brightness = round(255 * int(self._values[value_type]) / 100) if self._brightness == 0: self._state = False - if set_req.V_LIGHT not in self._values: - self._state = self._brightness > 0 def _update_rgb_or_w(self): """Update the controller with values from RGB or RGBW child.""" - set_req = self.gateway.const.SetReq value = self._values[self.value_type] - if len(value) != 6 and len(value) != 8: - _LOGGER.error( - "Wrong value %s for %s", value, set_req(self.value_type).name) - return color_list = rgb_hex_to_rgb_list(value) - if set_req.V_LIGHT not in self._values and \ - set_req.V_DIMMER not in self._values: - self._state = max(color_list) > 0 if len(color_list) > 3: - if set_req.V_RGBW != self.value_type: - _LOGGER.error( - "Wrong value %s for %s", - value, set_req(self.value_type).name) - return self._white = color_list.pop() self._rgb = color_list - def _update_main(self): - """Update the controller with the latest value from a sensor.""" - node = self.gateway.sensors[self.node_id] - child = node.children[self.child_id] - for value_type, value in child.values.items(): - _LOGGER.debug( - "%s: value_type %s, value = %s", self._name, value_type, value) - self._values[value_type] = value - class MySensorsLightDimmer(MySensorsLight): """Dimmer child class to MySensorsLight.""" @@ -270,18 +170,12 @@ def turn_on(self, **kwargs): """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) - - def turn_off(self, **kwargs): - """Turn the device off.""" - ret = self._turn_off_dimmer() - ret = self._turn_off_light( - value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE]) - self._turn_off_main( - value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE]) + if self.gateway.optimistic: + self.schedule_update_ha_state() def update(self): """Update the controller with the latest value from a sensor.""" - self._update_main() + super().update() self._update_light() self._update_dimmer() @@ -294,20 +188,12 @@ def turn_on(self, **kwargs): self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgb_and_w('%02x%02x%02x', **kwargs) - - def turn_off(self, **kwargs): - """Turn the device off.""" - ret = self._turn_off_rgb_or_w() - ret = self._turn_off_dimmer( - value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE]) - ret = self._turn_off_light( - value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE]) - self._turn_off_main( - value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE]) + if self.gateway.optimistic: + self.schedule_update_ha_state() def update(self): """Update the controller with the latest value from a sensor.""" - self._update_main() + super().update() self._update_light() self._update_dimmer() self._update_rgb_or_w() @@ -316,8 +202,12 @@ def update(self): class MySensorsLightRGBW(MySensorsLightRGB): """RGBW child class to MySensorsLightRGB.""" + # pylint: disable=too-many-ancestors + def turn_on(self, **kwargs): """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgb_and_w('%02x%02x%02x%02x', **kwargs) + if self.gateway.optimistic: + self.schedule_update_ha_state() diff --git a/homeassistant/components/light/rfxtrx.py b/homeassistant/components/light/rfxtrx.py index f831d6c04ce82..9248b0131f165 100644 --- a/homeassistant/components/light/rfxtrx.py +++ b/homeassistant/components/light/rfxtrx.py @@ -23,7 +23,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the RFXtrx platform.""" import RFXtrx as rfxtrxmod - lights = rfxtrx.get_devices_from_config(config, RfxtrxLight, hass) + lights = rfxtrx.get_devices_from_config(config, RfxtrxLight) add_devices(lights) def light_update(event): @@ -32,7 +32,7 @@ def light_update(event): not event.device.known_to_be_dimmable: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxLight, hass) + new_device = rfxtrx.get_new_device(event, config, RfxtrxLight) if new_device: add_devices([new_device]) diff --git a/homeassistant/components/light/rpi_gpio_pwm.py b/homeassistant/components/light/rpi_gpio_pwm.py index f2d39dea633ae..55b64bf8a74f3 100644 --- a/homeassistant/components/light/rpi_gpio_pwm.py +++ b/homeassistant/components/light/rpi_gpio_pwm.py @@ -14,7 +14,7 @@ SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pwmled==1.1.1'] +REQUIREMENTS = ['pwmled==1.2.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/light/tplink.py b/homeassistant/components/light/tplink.py index beca5fc6aec72..14288b8848d61 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -5,14 +5,17 @@ https://home-assistant.io/components/light.tplink/ """ import logging +import colorsys from homeassistant.const import (CONF_HOST, CONF_NAME) from homeassistant.components.light import ( - Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_KELVIN, - SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP) + Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_KELVIN, ATTR_RGB_COLOR, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR) from homeassistant.util.color import \ color_temperature_mired_to_kelvin as mired_to_kelvin -from homeassistant.util.color import \ - color_temperature_kelvin_to_mired as kelvin_to_mired +from homeassistant.util.color import ( + color_temperature_kelvin_to_mired as kelvin_to_mired) + +from typing import Tuple REQUIREMENTS = ['pyHS100==0.2.4.2'] @@ -39,10 +42,26 @@ def brightness_from_percentage(percent): return (percent*255.0)/100.0 +# Travis-CI runs too old astroid https://github.com/PyCQA/pylint/issues/1212 +# pylint: disable=invalid-sequence-index +def rgb_to_hsv(rgb: Tuple[float, float, float]) -> Tuple[int, int, int]: + """Convert RGB tuple (values 0-255) to HSV (degrees, %, %).""" + hue, sat, value = colorsys.rgb_to_hsv(rgb[0]/255, rgb[1]/255, rgb[2]/255) + return int(hue * 360), int(sat * 100), int(value * 100) + + +# Travis-CI runs too old astroid https://github.com/PyCQA/pylint/issues/1212 +# pylint: disable=invalid-sequence-index +def hsv_to_rgb(hsv: Tuple[float, float, float]) -> Tuple[int, int, int]: + """Convert HSV tuple (degrees, %, %) to RGB (values 0-255).""" + red, green, blue = colorsys.hsv_to_rgb(hsv[0]/360, hsv[1]/100, hsv[2]/100) + return int(red * 255), int(green * 255), int(blue * 255) + + class TPLinkSmartBulb(Light): """Representation of a TPLink Smart Bulb.""" - def __init__(self, smartbulb, name): + def __init__(self, smartbulb: 'SmartBulb', name): """Initialize the bulb.""" self.smartbulb = smartbulb @@ -55,6 +74,7 @@ def __init__(self, smartbulb, name): self._state = None self._color_temp = None self._brightness = None + self._rgb = None _LOGGER.debug("Setting up TP-Link Smart Bulb") @property @@ -64,6 +84,8 @@ def name(self): def turn_on(self, **kwargs): """Turn the light on.""" + self.smartbulb.state = self.smartbulb.BULB_STATE_ON + if ATTR_COLOR_TEMP in kwargs: self.smartbulb.color_temp = \ mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]) @@ -72,7 +94,9 @@ def turn_on(self, **kwargs): if ATTR_BRIGHTNESS in kwargs: brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness or 255) self.smartbulb.brightness = brightness_to_percentage(brightness) - self.smartbulb.state = self.smartbulb.BULB_STATE_ON + if ATTR_RGB_COLOR in kwargs: + rgb = kwargs.get(ATTR_RGB_COLOR) + self.smartbulb.hsv = rgb_to_hsv(rgb) def turn_off(self): """Turn the light off.""" @@ -88,6 +112,11 @@ def brightness(self): """Return the brightness of this light between 0..255.""" return self._brightness + @property + def rgb_color(self): + """Return the color in RGB.""" + return self._rgb + @property def is_on(self): """True if device is on.""" @@ -106,10 +135,14 @@ def update(self): self.smartbulb.color_temp != 0): self._color_temp = kelvin_to_mired( self.smartbulb.color_temp) + self._rgb = hsv_to_rgb(self.smartbulb.hsv) except (SmartPlugException, OSError) as ex: _LOGGER.warning('Could not read state for %s: %s', self.name, ex) @property def supported_features(self): """Flag supported features.""" - return SUPPORT_TPLINK + supported_features = SUPPORT_TPLINK + if self.smartbulb.is_color: + supported_features += SUPPORT_RGB_COLOR + return supported_features diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index b04640d7a8a6e..fa21af996cb3c 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -9,9 +9,10 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, Light) -from homeassistant.components.light import \ - PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA -from homeassistant.components.tradfri import KEY_GATEWAY, KEY_TRADFRI_GROUPS +from homeassistant.components.light import ( + PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA) +from homeassistant.components.tradfri import ( + KEY_GATEWAY, KEY_TRADFRI_GROUPS, KEY_API) from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) @@ -19,9 +20,7 @@ DEPENDENCIES = ['tradfri'] PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA IKEA = 'IKEA of Sweden' -ALLOWED_TEMPERATURES = { - IKEA: {2200: 'efd275', 2700: 'f1e0b5', 4000: 'f5faf6'} -} +ALLOWED_TEMPERATURES = {IKEA} def setup_platform(hass, config, add_devices, discovery_info=None): @@ -30,24 +29,26 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return gateway_id = discovery_info['gateway'] + api = hass.data[KEY_API][gateway_id] gateway = hass.data[KEY_GATEWAY][gateway_id] - devices = gateway.get_devices() - lights = [dev for dev in devices if dev.has_light_control] - add_devices(Tradfri(light) for light in lights) + devices = api(gateway.get_devices()) + lights = [dev for dev in devices if api(dev).has_light_control] + add_devices(Tradfri(light, api) for light in lights) allow_tradfri_groups = hass.data[KEY_TRADFRI_GROUPS][gateway_id] if allow_tradfri_groups: - groups = gateway.get_groups() - add_devices(TradfriGroup(group) for group in groups) + groups = api(gateway.get_groups()) + add_devices(TradfriGroup(group, api) for group in groups) class TradfriGroup(Light): """The platform class required by hass.""" - def __init__(self, light): + def __init__(self, light, api): """Initialize a Group.""" - self._group = light - self._name = light.name + self._group = api(light) + self._api = api + self._name = self._group.name @property def supported_features(self): @@ -71,20 +72,20 @@ def brightness(self): def turn_off(self, **kwargs): """Instruct the group lights to turn off.""" - self._group.set_state(0) + self._api(self._group.set_state(0)) def turn_on(self, **kwargs): """Instruct the group lights to turn on, or dim.""" if ATTR_BRIGHTNESS in kwargs: - self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS]) + self._api(self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS])) else: - self._group.set_state(1) + self._api(self._group.set_state(1)) def update(self): """Fetch new state data for this group.""" from pytradfri import RequestTimeout try: - self._group.update() + self._api(self._group.update()) except RequestTimeout: _LOGGER.warning("Tradfri update request timed out") @@ -92,14 +93,15 @@ def update(self): class Tradfri(Light): """The platform class required by Home Asisstant.""" - def __init__(self, light): + def __init__(self, light, api): """Initialize a Light.""" - self._light = light + self._light = api(light) + self._api = api # Caching of LightControl and light object - self._light_control = light.light_control - self._light_data = light.light_control.lights[0] - self._name = light.name + self._light_control = self._light.light_control + self._light_data = self._light_control.lights[0] + self._name = self._light.name self._rgb_color = None self._features = SUPPORT_BRIGHTNESS @@ -109,8 +111,20 @@ def __init__(self, light): else: self._features |= SUPPORT_RGB_COLOR - self._ok_temps = ALLOWED_TEMPERATURES.get( - self._light.device_info.manufacturer) + self._ok_temps = \ + self._light.device_info.manufacturer in ALLOWED_TEMPERATURES + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + from pytradfri.color import MAX_KELVIN_WS + return color_util.color_temperature_kelvin_to_mired(MAX_KELVIN_WS) + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + from pytradfri.color import MIN_KELVIN_WS + return color_util.color_temperature_kelvin_to_mired(MIN_KELVIN_WS) @property def supported_features(self): @@ -135,20 +149,13 @@ def brightness(self): @property def color_temp(self): """Return the CT color value in mireds.""" - if (self._light_data.hex_color is None or + if (self._light_data.kelvin_color is None or self.supported_features & SUPPORT_COLOR_TEMP == 0 or not self._ok_temps): return None - - kelvin = next(( - kelvin for kelvin, hex_color in self._ok_temps.items() - if hex_color == self._light_data.hex_color), None) - if kelvin is None: - _LOGGER.error( - "Unexpected color temperature found for %s: %s", - self.name, self._light_data.hex_color) - return - return color_util.color_temperature_kelvin_to_mired(kelvin) + return color_util.color_temperature_kelvin_to_mired( + self._light_data.kelvin_color + ) @property def rgb_color(self): @@ -157,7 +164,7 @@ def rgb_color(self): def turn_off(self, **kwargs): """Instruct the light to turn off.""" - self._light_control.set_state(False) + self._api(self._light_control.set_state(False)) def turn_on(self, **kwargs): """ @@ -167,29 +174,27 @@ def turn_on(self, **kwargs): for ATTR_RGB_COLOR, this also supports Philips Hue bulbs. """ if ATTR_BRIGHTNESS in kwargs: - self._light_control.set_dimmer(kwargs[ATTR_BRIGHTNESS]) + self._api(self._light_control.set_dimmer(kwargs[ATTR_BRIGHTNESS])) else: - self._light_control.set_state(True) + self._api(self._light_control.set_state(True)) if ATTR_RGB_COLOR in kwargs and self._light_data.hex_color is not None: - self._light.light_control.set_hex_color( - color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR])) + self._api(self._light.light_control.set_hex_color( + color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR]))) elif ATTR_COLOR_TEMP in kwargs and \ self._light_data.hex_color is not None and self._ok_temps: kelvin = color_util.color_temperature_mired_to_kelvin( kwargs[ATTR_COLOR_TEMP]) - # find closest allowed kelvin temp from user input - kelvin = min(self._ok_temps.keys(), key=lambda x: abs(x - kelvin)) - self._light_control.set_hex_color(self._ok_temps[kelvin]) + self._api(self._light_control.set_kelvin_color(kelvin)) def update(self): """Fetch new state data for this light.""" from pytradfri import RequestTimeout try: - self._light.update() - except RequestTimeout: - _LOGGER.warning("Tradfri update request timed out") + self._api(self._light.update()) + except RequestTimeout as exception: + _LOGGER.warning("Tradfri update request timed out: %s", exception) # Handle Hue lights paired with the gateway # hex_color is 0 when bulb is unreachable diff --git a/homeassistant/components/light/xiaomi_philipslight.py b/homeassistant/components/light/xiaomi_philipslight.py new file mode 100644 index 0000000000000..8df25153a733e --- /dev/null +++ b/homeassistant/components/light/xiaomi_philipslight.py @@ -0,0 +1,227 @@ +""" +Support for Xiaomi Philips Lights (LED Ball & Ceil). + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/light.xiaomi_philipslight/ +""" +import asyncio +from functools import partial +import logging + +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, ) + +from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, ) +from homeassistant.exceptions import PlatformNotReady + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Xiaomi Philips Light' +PLATFORM = 'xiaomi_philipslight' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + +REQUIREMENTS = ['python-mirobo==0.1.3'] + +# The light does not accept cct values < 1 +CCT_MIN = 1 +CCT_MAX = 100 + +SUCCESS = ['ok'] +ATTR_MODEL = 'model' + + +# pylint: disable=unused-argument +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the light from config.""" + from mirobo import Ceil, DeviceException + if PLATFORM not in hass.data: + hass.data[PLATFORM] = {} + + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + token = config.get(CONF_TOKEN) + + _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) + + try: + light = Ceil(host, token) + device_info = light.info() + _LOGGER.info("%s %s %s initialized", + device_info.raw['model'], + device_info.raw['fw_ver'], + device_info.raw['hw_ver']) + + philips_light = XiaomiPhilipsLight(name, light, device_info) + hass.data[PLATFORM][host] = philips_light + except DeviceException: + raise PlatformNotReady + + async_add_devices([philips_light], update_before_add=True) + + +class XiaomiPhilipsLight(Light): + """Representation of a Xiaomi Philips Light.""" + + def __init__(self, name, light, device_info): + """Initialize the light device.""" + self._name = name + self._device_info = device_info + + self._brightness = None + self._color_temp = None + + self._light = light + self._state = None + self._state_attrs = { + ATTR_MODEL: self._device_info.raw['model'], + } + + @property + def should_poll(self): + """Poll the light.""" + return True + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def available(self): + """Return true when state is known.""" + return self._state is not None + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._state_attrs + + @property + def is_on(self): + """Return true if light is on.""" + return self._state + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def color_temp(self): + """Return the color temperature.""" + return self._color_temp + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return 175 + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return 333 + + @property + def supported_features(self): + """Return the supported features.""" + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP + + @asyncio.coroutine + def _try_command(self, mask_error, func, *args, **kwargs): + """Call a light command handling error messages.""" + from mirobo import DeviceException + try: + result = yield from self.hass.async_add_job( + partial(func, *args, **kwargs)) + + _LOGGER.debug("Response received from light: %s", result) + + return result == SUCCESS + except DeviceException as exc: + _LOGGER.error(mask_error, exc) + return False + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + percent_brightness = int(100 * brightness / 255) + + _LOGGER.debug( + "Setting brightness: %s %s%%", + self.brightness, percent_brightness) + + result = yield from self._try_command( + "Setting brightness failed: %s", + self._light.set_bright, percent_brightness) + + if result: + self._brightness = brightness + + if ATTR_COLOR_TEMP in kwargs: + color_temp = kwargs[ATTR_COLOR_TEMP] + percent_color_temp = self.translate( + color_temp, self.max_mireds, + self.min_mireds, CCT_MIN, CCT_MAX) + + _LOGGER.debug( + "Setting color temperature: " + "%s mireds, %s%% cct", + color_temp, percent_color_temp) + + result = yield from self._try_command( + "Setting color temperature failed: %s cct", + self._light.set_cct, percent_color_temp) + + if result: + self._color_temp = color_temp + + result = yield from self._try_command( + "Turning the light on failed.", self._light.on) + + if result: + self._state = True + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the light off.""" + result = yield from self._try_command( + "Turning the light off failed.", self._light.off) + + if result: + self._state = True + + @asyncio.coroutine + def async_update(self): + """Fetch state from the device.""" + from mirobo import DeviceException + try: + state = yield from self.hass.async_add_job(self._light.status) + _LOGGER.debug("Got new state: %s", state.data) + + self._state = state.is_on + self._brightness = int(255 * 0.01 * state.bright) + self._color_temp = self.translate(state.cct, CCT_MIN, CCT_MAX, + self.max_mireds, + self.min_mireds) + + except DeviceException as ex: + _LOGGER.error("Got exception while fetching the state: %s", ex) + + @staticmethod + def translate(value, left_min, left_max, right_min, right_max): + """Map a value from left span to right span.""" + left_span = left_max - left_min + right_span = right_max - right_min + value_scaled = float(value - left_min) / float(left_span) + return int(right_min + (value_scaled * right_span)) diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index e286bf330a13a..1f7ee2ba5f937 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -6,6 +6,7 @@ """ import logging import colorsys +from typing import Tuple import voluptuous as vol @@ -89,6 +90,14 @@ EFFECT_STOP] +# Travis-CI runs too old astroid https://github.com/PyCQA/pylint/issues/1212 +# pylint: disable=invalid-sequence-index +def hsv_to_rgb(hsv: Tuple[float, float, float]) -> Tuple[int, int, int]: + """Convert HSV tuple (degrees, %, %) to RGB (values 0-255).""" + red, green, blue = colorsys.hsv_to_rgb(hsv[0]/360, hsv[1]/100, hsv[2]/100) + return int(red * 255), int(green * 255), int(blue * 255) + + def _cmd(func): """Define a wrapper to catch exceptions from the bulb.""" def _wrap(self, *args, **kwargs): @@ -192,10 +201,10 @@ def _get_rgb_from_properties(self): if color_mode == 2: # color temperature return color_temperature_to_rgb(self.color_temp) if color_mode == 3: # hsv - hue = self._properties.get('hue') - sat = self._properties.get('sat') - val = self._properties.get('bright') - return colorsys.hsv_to_rgb(hue, sat, val) + hue = int(self._properties.get('hue')) + sat = int(self._properties.get('sat')) + val = int(self._properties.get('bright')) + return hsv_to_rgb((hue, sat, val)) rgb = int(rgb) blue = rgb & 0xff @@ -214,7 +223,7 @@ def _properties(self) -> dict: return self._bulb.last_properties @property - def _bulb(self) -> object: + def _bulb(self) -> 'yeelight.Bulb': import yeelight if self._bulb_device is None: try: diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index 2a3ce18d74ef4..e7ba394a977d1 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -27,8 +27,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): endpoint = discovery_info['endpoint'] try: - primaries = yield from endpoint.light_color['num_primaries'] - discovery_info['num_primaries'] = primaries + discovery_info['color_capabilities'] \ + = yield from endpoint.light_color['color_capabilities'] except (AttributeError, KeyError): pass @@ -54,11 +54,11 @@ def __init__(self, **kwargs): self._supported_features |= light.SUPPORT_TRANSITION self._brightness = 0 if zcl_clusters.lighting.Color.cluster_id in self._in_clusters: - # Not sure all color lights necessarily support this directly - # Should we emulate it? - self._supported_features |= light.SUPPORT_COLOR_TEMP - # Silly heuristic, not sure if it works widely - if kwargs.get('num_primaries', 1) >= 3: + color_capabilities = kwargs.get('color_capabilities', 0x10) + if color_capabilities & 0x10: + self._supported_features |= light.SUPPORT_COLOR_TEMP + + if color_capabilities & 0x08: self._supported_features |= light.SUPPORT_XY_COLOR self._supported_features |= light.SUPPORT_RGB_COLOR self._xy_color = (1.0, 1.0) diff --git a/homeassistant/components/lock/abode.py b/homeassistant/components/lock/abode.py new file mode 100644 index 0000000000000..aad720e0d7d38 --- /dev/null +++ b/homeassistant/components/lock/abode.py @@ -0,0 +1,49 @@ +""" +This component provides HA lock support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.lock import LockDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode lock devices.""" + import abodepy.helpers.constants as CONST + + abode = hass.data[DATA_ABODE] + + sensors = [] + for sensor in abode.get_devices(type_filter=(CONST.DEVICE_DOOR_LOCK)): + sensors.append(AbodeLock(abode, sensor)) + + add_devices(sensors) + + +class AbodeLock(AbodeDevice, LockDevice): + """Representation of an Abode lock.""" + + def __init__(self, controller, device): + """Initialize the Abode device.""" + AbodeDevice.__init__(self, controller, device) + + def lock(self, **kwargs): + """Lock the device.""" + self._device.lock() + + def unlock(self, **kwargs): + """Unlock the device.""" + self._device.unlock() + + @property + def is_locked(self): + """Return true if device is on.""" + return self._device.is_locked diff --git a/homeassistant/components/lock/nello.py b/homeassistant/components/lock/nello.py new file mode 100644 index 0000000000000..04030c9242577 --- /dev/null +++ b/homeassistant/components/lock/nello.py @@ -0,0 +1,99 @@ +""" +Nello.io lock platform. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/lock.nello/ +""" +from itertools import filterfalse +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.lock import (LockDevice, PLATFORM_SCHEMA) +from homeassistant.const import (CONF_PASSWORD, CONF_USERNAME) + +REQUIREMENTS = ['pynello==1.5.1'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_ADDRESS = 'address' +ATTR_LOCATION_ID = 'location_id' +EVENT_DOOR_BELL = 'nello_bell_ring' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Nello lock platform.""" + from pynello import Nello + nello = Nello(config.get(CONF_USERNAME), config.get(CONF_PASSWORD)) + add_devices([NelloLock(lock) for lock in nello.locations], True) + + +class NelloLock(LockDevice): + """Representation of a Nello lock.""" + + def __init__(self, nello_lock): + """Initialize the lock.""" + self._nello_lock = nello_lock + self._device_attrs = None + self._activity = None + self._name = None + + @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 True + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + return self._device_attrs + + def update(self): + """Update the nello lock properties.""" + self._nello_lock.update() + # Location identifiers + location_id = self._nello_lock.location_id + short_id = self._nello_lock.short_id + address = self._nello_lock.address + self._name = 'Nello {}'.format(short_id) + self._device_attrs = { + ATTR_ADDRESS: address, + ATTR_LOCATION_ID: location_id + } + # Process recent activity + activity = self._nello_lock.activity + if self._activity: + # Filter out old events + new_activity = list( + filterfalse(lambda x: x in self._activity, activity)) + if new_activity: + for act in new_activity: + activity_type = act.get('type') + if activity_type == 'bell.ring.denied': + event_data = { + 'address': address, + 'date': act.get('date'), + 'description': act.get('description'), + 'location_id': location_id, + 'short_id': short_id + } + self.hass.bus.fire(EVENT_DOOR_BELL, event_data) + # Save the activity history so that we don't trigger an event twice + self._activity = activity + + def unlock(self, **kwargs): + """Unlock the device.""" + if not self._nello_lock.open_door(): + _LOGGER.error("Failed to unlock") diff --git a/homeassistant/components/lock/tesla.py b/homeassistant/components/lock/tesla.py new file mode 100644 index 0000000000000..3e93e4787a0d5 --- /dev/null +++ b/homeassistant/components/lock/tesla.py @@ -0,0 +1,57 @@ +""" +Support for Tesla door locks. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.tesla/ +""" +import logging + +from homeassistant.components.lock import ENTITY_ID_FORMAT, LockDevice +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla lock platform.""" + devices = [TeslaLock(device, hass.data[TESLA_DOMAIN]['controller']) + for device in hass.data[TESLA_DOMAIN]['devices']['lock']] + add_devices(devices, True) + + +class TeslaLock(TeslaDevice, LockDevice): + """Representation of a Tesla door lock.""" + + def __init__(self, tesla_device, controller): + """Initialisation of the lock.""" + self._state = None + super().__init__(tesla_device, controller) + self._name = self.tesla_device.name + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + + def lock(self, **kwargs): + """Send the lock command.""" + _LOGGER.debug("Locking doors for: %s", self._name) + self.tesla_device.lock() + self._state = STATE_LOCKED + + def unlock(self, **kwargs): + """Send the unlock command.""" + _LOGGER.debug("Unlocking doors for: %s", self._name) + self.tesla_device.unlock() + self._state = STATE_UNLOCKED + + @property + def is_locked(self): + """Get whether the lock is in locked state.""" + return self._state == STATE_LOCKED + + def update(self): + """Updating state of the lock.""" + _LOGGER.debug("Updating state for: %s", self._name) + self.tesla_device.update() + self._state = STATE_LOCKED if self.tesla_device.is_locked() \ + else STATE_UNLOCKED diff --git a/homeassistant/components/lutron_caseta.py b/homeassistant/components/lutron_caseta.py index dcb3347e9196c..8660546c910c3 100644 --- a/homeassistant/components/lutron_caseta.py +++ b/homeassistant/components/lutron_caseta.py @@ -14,7 +14,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pylutron-caseta==0.2.7'] +REQUIREMENTS = ['pylutron-caseta==0.2.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 691e11d01eb9c..1ecb09ac022bf 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -15,7 +15,7 @@ from homeassistant.config import load_yaml_config_file from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2017.8.6'] +REQUIREMENTS = ['youtube_dl==2017.9.2'] _LOGGER = logging.getLogger(__name__) @@ -42,7 +42,7 @@ def setup(hass, config): 'media_player', 'services.yaml')) def play_media(call): - """Get stream URL and send it to the media_player.play_media.""" + """Get stream URL and send it to the play_media service.""" MediaExtractor(hass, config[DOMAIN], call.data).extract_and_send() hass.services.register(DOMAIN, @@ -66,7 +66,7 @@ class MEQueryException(Exception): pass -class MediaExtractor: +class MediaExtractor(object): """Class which encapsulates all extraction logic.""" def __init__(self, hass, component_config, call_data): @@ -107,15 +107,14 @@ def get_stream_selector(self): ydl = YoutubeDL({'quiet': True, 'logger': _LOGGER}) try: - all_media = ydl.extract_info(self.get_media_url(), - process=False) + all_media = ydl.extract_info(self.get_media_url(), process=False) except DownloadError: # This exception will be logged by youtube-dl itself raise MEDownloadException() if 'entries' in all_media: - _LOGGER.warning("Playlists are not supported, " - "looking for the first video") + _LOGGER.warning( + "Playlists are not supported, looking for the first video") entries = list(all_media['entries']) if len(entries) > 0: selected_media = entries[0] @@ -126,14 +125,14 @@ def get_stream_selector(self): selected_media = all_media def stream_selector(query): - """Find stream url that matches query.""" + """Find stream URL that matches query.""" try: ydl.params['format'] = query - requested_stream = ydl.process_ie_result(selected_media, - download=False) + requested_stream = ydl.process_ie_result( + selected_media, download=False) except (ExtractorError, DownloadError): - _LOGGER.error("Could not extract stream for the query: %s", - query) + _LOGGER.error( + "Could not extract stream for the query: %s", query) raise MEQueryException() return requested_stream['url'] @@ -141,7 +140,7 @@ def stream_selector(query): return stream_selector def call_media_player_service(self, stream_selector, entity_id): - """Call media_player.play_media service.""" + """Call Media player play_media service.""" stream_query = self.get_stream_query_for_entity(entity_id) try: @@ -164,8 +163,8 @@ def call_media_player_service(self, stream_selector, entity_id): def get_stream_query_for_entity(self, entity_id): """Get stream format query for entity.""" - default_stream_query = self.config.get(CONF_DEFAULT_STREAM_QUERY, - DEFAULT_STREAM_QUERY) + default_stream_query = self.config.get( + CONF_DEFAULT_STREAM_QUERY, DEFAULT_STREAM_QUERY) if entity_id: media_content_type = self.call_data.get(ATTR_MEDIA_CONTENT_TYPE) diff --git a/homeassistant/components/media_player/braviatv.py b/homeassistant/components/media_player/braviatv.py index 93071b9840f2b..399052611c15e 100644 --- a/homeassistant/components/media_player/braviatv.py +++ b/homeassistant/components/media_player/braviatv.py @@ -11,7 +11,6 @@ import voluptuous as vol -from homeassistant.loader import get_component from homeassistant.components.media_player import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, SUPPORT_PLAY, @@ -132,7 +131,7 @@ def setup_bravia(config, pin, hass, add_devices): # If we came here and configuring this host, mark as done if host in _CONFIGURING: request_id = _CONFIGURING.pop(host) - configurator = get_component('configurator') + configurator = hass.components.configurator configurator.request_done(request_id) _LOGGER.info("Discovery configuration done") @@ -150,7 +149,7 @@ def request_configuration(config, hass, add_devices): host = config.get(CONF_HOST) name = config.get(CONF_NAME) - configurator = get_component('configurator') + configurator = hass.components.configurator # We got an error if this method is called while we are configuring if host in _CONFIGURING: @@ -171,7 +170,7 @@ def bravia_configuration_callback(data): request_configuration(config, hass, add_devices) _CONFIGURING[host] = configurator.request_config( - hass, name, bravia_configuration_callback, + name, bravia_configuration_callback, description='Enter the Pin shown on your Sony Bravia TV.' + 'If no Pin is shown, enter 0000 to let TV show you a Pin.', description_image="/static/images/smart-tv.png", diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index 06f95a7d3a7f4..9433951471286 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -17,15 +17,16 @@ MEDIA_TYPE_MUSIC, SUPPORT_VOLUME_SET, SUPPORT_PLAY) from homeassistant.const import ( CONF_HOST, STATE_OFF, STATE_PLAYING, STATE_PAUSED, - CONF_NAME, STATE_ON, CONF_ZONE) + CONF_NAME, STATE_ON, CONF_ZONE, CONF_TIMEOUT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.5.2'] +REQUIREMENTS = ['denonavr==0.5.3'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = None DEFAULT_SHOW_SOURCES = False +DEFAULT_TIMEOUT = 2 CONF_SHOW_ALL_SOURCES = 'show_all_sources' CONF_ZONES = 'zones' CONF_VALID_ZONES = ['Zone2', 'Zone3'] @@ -51,7 +52,8 @@ vol.Optional(CONF_SHOW_ALL_SOURCES, default=DEFAULT_SHOW_SOURCES): cv.boolean, vol.Optional(CONF_ZONES): - vol.All(cv.ensure_list, [DENON_ZONE_SCHEMA]) + vol.All(cv.ensure_list, [DENON_ZONE_SCHEMA]), + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, }) NewHost = namedtuple('NewHost', ['host', 'name']) @@ -69,8 +71,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if cache is None: cache = hass.data[KEY_DENON_CACHE] = set() - # Get config option for show_all_sources + # Get config option for show_all_sources and timeout show_all_sources = config.get(CONF_SHOW_ALL_SOURCES) + timeout = config.get(CONF_TIMEOUT) # Get config option for additional zones zones = config.get(CONF_ZONES) @@ -103,14 +106,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for d_receiver in d_receivers: host = d_receiver["host"] name = d_receiver["friendlyName"] - new_hosts.append(NewHost(host=host, name=name)) + new_hosts.append( + NewHost(host=host, name=name)) for entry in new_hosts: # Check if host not in cache, append it and save for later # starting if entry.host not in cache: new_device = denonavr.DenonAVR( - entry.host, entry.name, show_all_sources, add_zones) + host=entry.host, name=entry.name, + show_all_inputs=show_all_sources, timeout=timeout, + add_zones=add_zones) for new_zone in new_device.zones.values(): receivers.append(DenonDevice(new_zone)) cache.add(host) diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index 599b8fbbd7180..a334dc7caa4c7 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -5,6 +5,7 @@ https://home-assistant.io/components/media_player.directv/ """ import voluptuous as vol +import requests from homeassistant.components.media_player import ( MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, @@ -25,7 +26,7 @@ SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_NEXT_TRACK | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY -KNOWN_HOSTS = [] +DATA_DIRECTV = "data_directv" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -37,32 +38,45 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the DirecTV platform.""" + known_devices = hass.data.get(DATA_DIRECTV) + if not known_devices: + known_devices = [] hosts = [] - if discovery_info: - host = discovery_info.get('host') - - if host in KNOWN_HOSTS: - return - - hosts.append([ - 'DirecTV_' + discovery_info.get('serial', ''), - host, DEFAULT_PORT - ]) - - elif CONF_HOST in config: + if CONF_HOST in config: hosts.append([ config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT), config.get(CONF_DEVICE) ]) + elif discovery_info: + host = discovery_info.get('host') + name = 'DirecTV_' + discovery_info.get('serial', '') + + # attempt to discover additional RVU units + try: + resp = requests.get( + 'http://%s:%d/info/getLocations' % (host, DEFAULT_PORT)).json() + if "locations" in resp: + for loc in resp["locations"]: + if("locationName" in loc and "clientAddr" in loc + and loc["clientAddr"] not in known_devices): + hosts.append([str.title(loc["locationName"]), host, + DEFAULT_PORT, loc["clientAddr"]]) + + except requests.exceptions.RequestException: + # bail out and just go forward with uPnP data + if DEFAULT_DEVICE not in known_devices: + hosts.append([name, host, DEFAULT_PORT, DEFAULT_DEVICE]) + dtvs = [] for host in hosts: dtvs.append(DirecTvDevice(*host)) - KNOWN_HOSTS.append(host) + known_devices.append(host[-1]) add_devices(dtvs) + hass.data[DATA_DIRECTV] = known_devices return True diff --git a/homeassistant/components/media_player/gpmdp.py b/homeassistant/components/media_player/gpmdp.py index 269964ea6c75e..4090f4208552a 100644 --- a/homeassistant/components/media_player/gpmdp.py +++ b/homeassistant/components/media_player/gpmdp.py @@ -18,7 +18,6 @@ MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import ( STATE_PLAYING, STATE_PAUSED, STATE_OFF, CONF_HOST, CONF_PORT, CONF_NAME) -from homeassistant.loader import get_component import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['websocket-client==0.37.0'] @@ -48,7 +47,7 @@ def request_configuration(hass, config, url, add_devices_callback): """Request configuration steps from the user.""" - configurator = get_component('configurator') + configurator = hass.components.configurator if 'gpmdp' in _CONFIGURING: configurator.notify_errors( _CONFIGURING['gpmdp'], "Failed to register, please try again.") @@ -96,7 +95,7 @@ def gpmdp_configuration_callback(callback_data): break _CONFIGURING['gpmdp'] = configurator.request_config( - hass, DEFAULT_NAME, gpmdp_configuration_callback, + DEFAULT_NAME, gpmdp_configuration_callback, description=( 'Enter the pin that is displayed in the ' 'Google Play Music Desktop Player.'), @@ -117,7 +116,7 @@ def setup_gpmdp(hass, config, code, add_devices): return if 'gpmdp' in _CONFIGURING: - configurator = get_component('configurator') + configurator = hass.components.configurator configurator.request_done(_CONFIGURING.pop('gpmdp')) add_devices([GPMDP(name, url, code)], True) diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 793588a8d9fb1..55df1e367a47b 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -79,14 +79,15 @@ def __init__(self, server, port, password, name): self._client = mpd.MPDClient() self._client.timeout = 5 self._client.idletimeout = None - if password is not None: - self._client.password(password) def _connect(self): """Connect to MPD.""" import mpd try: self._client.connect(self.server, self.port) + + if self.password is not None: + self._client.password(self.password) except mpd.ConnectionError: return diff --git a/homeassistant/components/media_player/onkyo.py b/homeassistant/components/media_player/onkyo.py index 927de799ae9c3..97ebe5be92b04 100644 --- a/homeassistant/components/media_player/onkyo.py +++ b/homeassistant/components/media_player/onkyo.py @@ -14,7 +14,7 @@ from homeassistant.const import (STATE_OFF, STATE_ON, CONF_HOST, CONF_NAME) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['onkyo-eiscp==1.1'] +REQUIREMENTS = ['onkyo-eiscp==1.2.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index f4c69ba1fe665..a901cd1d569f3 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -23,7 +23,6 @@ DEVICE_DEFAULT_NAME, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_utc_time_change -from homeassistant.loader import get_component REQUIREMENTS = ['plexapi==2.0.2'] @@ -143,7 +142,7 @@ def setup_plexserver( # If we came here and configuring this host, mark as done if host in _CONFIGURING: request_id = _CONFIGURING.pop(host) - configurator = get_component('configurator') + configurator = hass.components.configurator configurator.request_done(request_id) _LOGGER.info("Discovery configuration done") @@ -236,7 +235,7 @@ def update_sessions(): def request_configuration(host, hass, config, add_devices_callback): """Request configuration steps from the user.""" - configurator = get_component('configurator') + configurator = hass.components.configurator # We got an error if this method is called while we are configuring if host in _CONFIGURING: configurator.notify_errors(_CONFIGURING[host], @@ -254,7 +253,6 @@ def plex_configuration_callback(data): ) _CONFIGURING[host] = configurator.request_config( - hass, 'Plex Media Server', plex_configuration_callback, description=('Enter the X-Plex-Token'), diff --git a/homeassistant/components/media_player/snapcast.py b/homeassistant/components/media_player/snapcast.py index 7b0036e5f9624..1715f0f18299b 100644 --- a/homeassistant/components/media_player/snapcast.py +++ b/homeassistant/components/media_player/snapcast.py @@ -20,7 +20,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.config import load_yaml_config_file -REQUIREMENTS = ['snapcast==2.0.6'] +REQUIREMENTS = ['snapcast==2.0.7'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 63d27299aa772..a5ef91ecc87a3 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -322,6 +322,7 @@ def __init__(self, player): self._media_title = None self._media_radio_show = None self._media_next_title = None + self._available = True self._support_previous_track = False self._support_next_track = False self._support_play = False @@ -386,6 +387,11 @@ def coordinator(self): """Return coordinator of this player.""" return self._coordinator + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + def _is_available(self): try: sock = socket.create_connection( @@ -416,11 +422,11 @@ def update(self): self._player.get_sonos_favorites()['favorites'] if self._last_avtransport_event: - is_available = True + self._available = True else: - is_available = self._is_available() + self._available = self._is_available() - if not is_available: + if not self._available: self._player_volume = None self._player_volume_muted = None self._status = 'OFF' @@ -897,7 +903,8 @@ def select_source(self, source): src = fav.pop() self._source_name = src['title'] - if 'object.container.playlistContainer' in src['meta']: + if ('object.container.playlistContainer' in src['meta'] or + 'object.container.album.musicAlbum' in src['meta']): self._replace_queue_with_playlist(src) self._player.play_from_queue(0) else: diff --git a/homeassistant/components/media_player/spotify.py b/homeassistant/components/media_player/spotify.py index bc0728c7ff24f..734285d918a33 100644 --- a/homeassistant/components/media_player/spotify.py +++ b/homeassistant/components/media_player/spotify.py @@ -10,7 +10,6 @@ import voluptuous as vol from homeassistant.core import callback -from homeassistant.loader import get_component from homeassistant.components.http import HomeAssistantView from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, SUPPORT_VOLUME_SET, @@ -62,9 +61,9 @@ def request_configuration(hass, config, add_devices, oauth): """Request Spotify authorization.""" - configurator = get_component('configurator') + configurator = hass.components.configurator hass.data[DOMAIN] = configurator.request_config( - hass, DEFAULT_NAME, lambda _: None, + DEFAULT_NAME, lambda _: None, link_name=CONFIGURATOR_LINK_NAME, link_url=oauth.get_authorize_url(), description=CONFIGURATOR_DESCRIPTION, @@ -88,7 +87,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): request_configuration(hass, config, add_devices, oauth) return if hass.data.get(DOMAIN): - configurator = get_component('configurator') + configurator = hass.components.configurator configurator.request_done(hass.data.get(DOMAIN)) del hass.data[DOMAIN] player = SpotifyMediaPlayer(oauth, config.get(CONF_NAME, DEFAULT_NAME), @@ -149,6 +148,10 @@ def refresh_spotify_instance(self): new_token = \ self._oauth.refresh_access_token( self._token_info['refresh_token']) + # skip when refresh failed + if new_token is None: + return + self._token_info = new_token token_refreshed = True if self._player is None or token_refreshed: @@ -159,6 +162,12 @@ def refresh_spotify_instance(self): def update(self): """Update state and attributes.""" self.refresh_spotify_instance() + + # Don't true update when token is expired + if self._oauth.is_token_expired(self._token_info): + _LOGGER.warning("Spotify failed to update, token expired.") + return + # Available devices player_devices = self._player.devices() if player_devices is not None: @@ -186,7 +195,8 @@ def update(self): self._artist = ', '.join([artist.get('name') for artist in item.get('artists')]) self._uri = current.get('uri') - self._image_url = item.get('album').get('images')[0].get('url') + images = item.get('album').get('images') + self._image_url = images[0].get('url') if images else None # Playing state self._state = STATE_PAUSED if current.get('is_playing'): diff --git a/homeassistant/components/media_player/squeezebox.py b/homeassistant/components/media_player/squeezebox.py index 859d596f98f5e..a4a15fbce2444 100644 --- a/homeassistant/components/media_player/squeezebox.py +++ b/homeassistant/components/media_player/squeezebox.py @@ -95,7 +95,8 @@ def create_players(self): """Create a list of devices connected to LMS.""" result = [] data = yield from self.async_query('players', 'status') - + if data is False: + return result for players in data.get('players_loop', []): player = SqueezeBoxDevice( self, players['playerid'], players['name']) diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index 112b84ec5f0d9..65a999528c3b4 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -19,10 +19,9 @@ SUPPORT_SELECT_SOURCE, SUPPORT_PLAY_MEDIA, MEDIA_TYPE_CHANNEL, MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import ( - CONF_HOST, CONF_MAC, CONF_CUSTOMIZE, STATE_OFF, + CONF_HOST, CONF_MAC, CONF_CUSTOMIZE, CONF_TIMEOUT, STATE_OFF, STATE_PLAYING, STATE_PAUSED, STATE_UNKNOWN, CONF_NAME, CONF_FILENAME) -from homeassistant.loader import get_component import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['pylgtv==0.1.7', @@ -56,7 +55,8 @@ vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_MAC): cv.string, vol.Optional(CONF_CUSTOMIZE, default={}): CUSTOMIZE_SCHEMA, - vol.Optional(CONF_FILENAME, default=WEBOSTV_CONFIG_FILE): cv.string + vol.Optional(CONF_FILENAME, default=WEBOSTV_CONFIG_FILE): cv.string, + vol.Optional(CONF_TIMEOUT, default=10): cv.positive_int, }) @@ -79,17 +79,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None): mac = config.get(CONF_MAC) name = config.get(CONF_NAME) customize = config.get(CONF_CUSTOMIZE) + timeout = config.get(CONF_TIMEOUT) config = hass.config.path(config.get(CONF_FILENAME)) - setup_tv(host, mac, name, customize, config, hass, add_devices) + setup_tv(host, mac, name, customize, config, timeout, hass, add_devices) -def setup_tv(host, mac, name, customize, config, hass, add_devices): +def setup_tv(host, mac, name, customize, config, timeout, hass, add_devices): """Set up a LG WebOS TV based on host parameter.""" from pylgtv import WebOsClient from pylgtv import PyLGTVPairException from websockets.exceptions import ConnectionClosed - client = WebOsClient(host, config) + client = WebOsClient(host, config, timeout) if not client.is_registered(): if host in _CONFIGURING: @@ -100,30 +101,30 @@ def setup_tv(host, mac, name, customize, config, hass, add_devices): _LOGGER.warning( "Connected to LG webOS TV %s but not paired", host) return - except (OSError, ConnectionClosed, TypeError, - asyncio.TimeoutError): + except (OSError, ConnectionClosed, asyncio.TimeoutError): _LOGGER.error("Unable to connect to host %s", host) return else: # Not registered, request configuration. _LOGGER.warning("LG webOS TV %s needs to be paired", host) request_configuration( - host, mac, name, customize, config, hass, add_devices) + host, mac, name, customize, config, timeout, hass, add_devices) return # If we came here and configuring this host, mark as done. if client.is_registered() and host in _CONFIGURING: request_id = _CONFIGURING.pop(host) - configurator = get_component('configurator') + configurator = hass.components.configurator configurator.request_done(request_id) - add_devices([LgWebOSDevice(host, mac, name, customize, config)], True) + add_devices([LgWebOSDevice(host, mac, name, customize, config, timeout)], + True) def request_configuration( - host, mac, name, customize, config, hass, add_devices): + host, mac, name, customize, config, timeout, hass, add_devices): """Request configuration steps from the user.""" - configurator = get_component('configurator') + configurator = hass.components.configurator # We got an error if this method is called while we are configuring if host in _CONFIGURING: @@ -133,11 +134,12 @@ def request_configuration( # pylint: disable=unused-argument def lgtv_configuration_callback(data): - """Handle configuration changes.""" - setup_tv(host, mac, name, customize, config, hass, add_devices) + """The actions to do when our configuration callback is called.""" + setup_tv(host, mac, name, customize, config, timeout, hass, + add_devices) _CONFIGURING[host] = configurator.request_config( - hass, name, lgtv_configuration_callback, + name, lgtv_configuration_callback, description='Click start and accept the pairing request on your TV.', description_image='/static/images/config_webos.png', submit_caption='Start pairing request' @@ -147,11 +149,11 @@ def lgtv_configuration_callback(data): class LgWebOSDevice(MediaPlayerDevice): """Representation of a LG WebOS TV.""" - def __init__(self, host, mac, name, customize, config): + def __init__(self, host, mac, name, customize, config, timeout): """Initialize the webos device.""" from pylgtv import WebOsClient from wakeonlan import wol - self._client = WebOsClient(host, config) + self._client = WebOsClient(host, config, timeout) self._wol = wol self._mac = mac self._customize = customize diff --git a/homeassistant/components/media_player/yamaha_musiccast.py b/homeassistant/components/media_player/yamaha_musiccast.py new file mode 100644 index 0000000000000..88d17b4d6274a --- /dev/null +++ b/homeassistant/components/media_player/yamaha_musiccast.py @@ -0,0 +1,233 @@ +"""Example for configuration.yaml. + +media_player: + - platform: yamaha_musiccast + name: "Living Room" + host: 192.168.xxx.xx + port: 5005 + +""" + +import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv + +from homeassistant.const import ( + CONF_NAME, CONF_HOST, CONF_PORT, + STATE_UNKNOWN, STATE_ON +) +from homeassistant.components.media_player import ( + MediaPlayerDevice, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, + SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_PLAY, + SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE, + SUPPORT_SELECT_SOURCE, SUPPORT_STOP +) +_LOGGER = logging.getLogger(__name__) + +SUPPORTED_FEATURES = ( + SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | + SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | + SUPPORT_SELECT_SOURCE +) + +REQUIREMENTS = ['pymusiccast==0.1.0'] + +DEFAULT_NAME = "Yamaha Receiver" +DEFAULT_PORT = 5005 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.positive_int, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Yamaha MusicCast platform.""" + import pymusiccast + + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + + receiver = pymusiccast.McDevice(host, udp_port=port) + _LOGGER.debug("receiver: %s / Port: %d", receiver, port) + + add_devices([YamahaDevice(receiver, name)], True) + + +class YamahaDevice(MediaPlayerDevice): + """Representation of a Yamaha MusicCast device.""" + + def __init__(self, receiver, name): + """Initialize the Yamaha MusicCast device.""" + self._receiver = receiver + self._name = name + self.power = STATE_UNKNOWN + self.volume = 0 + self.volume_max = 0 + self.mute = False + self._source = None + self._source_list = [] + self.status = STATE_UNKNOWN + self.media_status = None + self._receiver.set_yamaha_device(self) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + if self.power == STATE_ON and self.status is not STATE_UNKNOWN: + return self.status + return self.power + + @property + def should_poll(self): + """Push an update after each command.""" + return True + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self.mute + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self.volume + + @property + def supported_features(self): + """Flag of features that are supported.""" + return SUPPORTED_FEATURES + + @property + def source(self): + """Return the current input source.""" + return self._source + + @property + def source_list(self): + """List of available input sources.""" + return self._source_list + + @source_list.setter + def source_list(self, value): + """Set source_list attribute.""" + self._source_list = value + + @property + def media_content_type(self): + """Return the media content type.""" + return MEDIA_TYPE_MUSIC + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self.media_status.media_duration \ + if self.media_status else None + + @property + def media_image_url(self): + """Image url of current playing media.""" + return self.media_status.media_image_url \ + if self.media_status else None + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + return self.media_status.media_artist if self.media_status else None + + @property + def media_album(self): + """Album of current playing media, music track only.""" + return self.media_status.media_album if self.media_status else None + + @property + def media_track(self): + """Track number of current playing media, music track only.""" + return self.media_status.media_track if self.media_status else None + + @property + def media_title(self): + """Title of current playing media.""" + return self.media_status.media_title if self.media_status else None + + def update(self): + """Get the latest details from the device.""" + _LOGGER.debug("update: %s", self.entity_id) + + # call from constructor setup_platform() + if not self.entity_id: + _LOGGER.debug("First run") + self._receiver.update_status(push=False) + # call from regular polling + else: + # update_status_timer was set before + if self._receiver.update_status_timer: + _LOGGER.debug( + "is_alive: %s", + self._receiver.update_status_timer.is_alive()) + # e.g. computer was suspended, while hass was running + if not self._receiver.update_status_timer.is_alive(): + _LOGGER.debug("Reinitializing") + self._receiver.update_status() + + def turn_on(self): + """Turn on specified media player or all.""" + _LOGGER.debug("Turn device: on") + self._receiver.set_power(True) + + def turn_off(self): + """Turn off specified media player or all.""" + _LOGGER.debug("Turn device: off") + self._receiver.set_power(False) + + def media_play(self): + """Send the media player the command for play/pause.""" + _LOGGER.debug("Play") + self._receiver.set_playback("play") + + def media_pause(self): + """Send the media player the command for pause.""" + _LOGGER.debug("Pause") + self._receiver.set_playback("pause") + + def media_stop(self): + """Send the media player the stop command.""" + _LOGGER.debug("Stop") + self._receiver.set_playback("stop") + + def media_previous_track(self): + """Send the media player the command for prev track.""" + _LOGGER.debug("Previous") + self._receiver.set_playback("previous") + + def media_next_track(self): + """Send the media player the command for next track.""" + _LOGGER.debug("Next") + self._receiver.set_playback("next") + + def mute_volume(self, mute): + """Send mute command.""" + _LOGGER.debug("Mute volume: %s", mute) + self._receiver.set_mute(mute) + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + _LOGGER.debug("Volume level: %.2f / %d", + volume, volume * self.volume_max) + self._receiver.set_volume(volume * self.volume_max) + + def select_source(self, source): + """Send the media player the command to select input source.""" + _LOGGER.debug("select_source: %s", source) + self.status = STATE_UNKNOWN + self._receiver.set_input(source) diff --git a/homeassistant/components/mycroft.py b/homeassistant/components/mycroft.py new file mode 100644 index 0000000000000..834572bc551f1 --- /dev/null +++ b/homeassistant/components/mycroft.py @@ -0,0 +1,35 @@ +""" +Support for Mycroft AI. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mycroft +""" + +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_HOST +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['mycroftapi==2.0'] + +_LOGGER = logging.getLogger(__name__) + + +DOMAIN = 'mycroft' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Mycroft component.""" + hass.data[DOMAIN] = config[DOMAIN][CONF_HOST] + discovery.load_platform(hass, 'notify', DOMAIN, {}, config) + return True diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index ef863bfb34f50..c37116fb32dcf 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -4,30 +4,37 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.mysensors/ """ +import asyncio +from collections import defaultdict import logging import os import socket import sys +from timeit import default_timer as timer import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.setup import setup_component from homeassistant.components.mqtt import ( valid_publish_topic, valid_subscribe_topic) from homeassistant.const import ( ATTR_BATTERY_LEVEL, CONF_NAME, CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_ON) from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, dispatcher_send) +from homeassistant.helpers.entity import Entity from homeassistant.loader import get_component +from homeassistant.setup import setup_component -REQUIREMENTS = ['pymysensors==0.10.0'] +REQUIREMENTS = ['pymysensors==0.11.1'] _LOGGER = logging.getLogger(__name__) ATTR_CHILD_ID = 'child_id' ATTR_DESCRIPTION = 'description' ATTR_DEVICE = 'device' +ATTR_DEVICES = 'devices' ATTR_NODE_ID = 'node_id' CONF_BAUD_RATE = 'baud_rate' @@ -42,13 +49,21 @@ CONF_TOPIC_OUT_PREFIX = 'topic_out_prefix' CONF_VERSION = 'version' +CONF_NODES = 'nodes' +CONF_NODE_NAME = 'name' + DEFAULT_BAUD_RATE = 115200 DEFAULT_TCP_PORT = 5003 -DEFAULT_VERSION = 1.4 +DEFAULT_VERSION = '1.4' DOMAIN = 'mysensors' MQTT_COMPONENT = 'mqtt' MYSENSORS_GATEWAYS = 'mysensors_gateways' +MYSENSORS_PLATFORM_DEVICES = 'mysensors_devices_{}' +PLATFORM = 'platform' +SCHEMA = 'schema' +SIGNAL_CALLBACK = 'mysensors_callback_{}_{}_{}_{}' +TYPE = 'type' def is_socket_address(value): @@ -120,6 +135,12 @@ def validator(config): return validator +NODE_SCHEMA = vol.Schema({ + cv.positive_int: { + vol.Required(CONF_NODE_NAME): cv.string + } +}) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema(vol.All(deprecated(CONF_DEBUG), { vol.Required(CONF_GATEWAYS): vol.All( @@ -139,16 +160,133 @@ def validator(config): CONF_TOPIC_IN_PREFIX, default=''): valid_subscribe_topic, vol.Optional( CONF_TOPIC_OUT_PREFIX, default=''): valid_publish_topic, + vol.Optional(CONF_NODES, default={}): NODE_SCHEMA, }] ), vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean, vol.Optional(CONF_RETAIN, default=True): cv.boolean, - vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.Coerce(float), + vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, })) }, extra=vol.ALLOW_EXTRA) +# mysensors const schemas +BINARY_SENSOR_SCHEMA = {PLATFORM: 'binary_sensor', TYPE: 'V_TRIPPED'} +CLIMATE_SCHEMA = {PLATFORM: 'climate', TYPE: 'V_HVAC_FLOW_STATE'} +LIGHT_DIMMER_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_DIMMER', + SCHEMA: {'V_DIMMER': cv.string, 'V_LIGHT': cv.string}} +LIGHT_PERCENTAGE_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_PERCENTAGE', + SCHEMA: {'V_PERCENTAGE': cv.string, 'V_STATUS': cv.string}} +LIGHT_RGB_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_RGB', SCHEMA: { + 'V_RGB': cv.string, 'V_STATUS': cv.string}} +LIGHT_RGBW_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_RGBW', SCHEMA: { + 'V_RGBW': cv.string, 'V_STATUS': cv.string}} +NOTIFY_SCHEMA = {PLATFORM: 'notify', TYPE: 'V_TEXT'} +DEVICE_TRACKER_SCHEMA = {PLATFORM: 'device_tracker', TYPE: 'V_POSITION'} +DUST_SCHEMA = [ + {PLATFORM: 'sensor', TYPE: 'V_DUST_LEVEL'}, + {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}] +SWITCH_LIGHT_SCHEMA = {PLATFORM: 'switch', TYPE: 'V_LIGHT'} +SWITCH_STATUS_SCHEMA = {PLATFORM: 'switch', TYPE: 'V_STATUS'} +MYSENSORS_CONST_SCHEMA = { + 'S_DOOR': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_MOTION': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_SMOKE': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_SPRINKLER': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_STATUS'}], + 'S_WATER_LEAK': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_SOUND': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, + {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_VIBRATION': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, + {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_MOISTURE': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, + {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_HVAC': [CLIMATE_SCHEMA], + 'S_COVER': [ + {PLATFORM: 'cover', TYPE: 'V_DIMMER'}, + {PLATFORM: 'cover', TYPE: 'V_PERCENTAGE'}, + {PLATFORM: 'cover', TYPE: 'V_LIGHT'}, + {PLATFORM: 'cover', TYPE: 'V_STATUS'}], + 'S_DIMMER': [LIGHT_DIMMER_SCHEMA, LIGHT_PERCENTAGE_SCHEMA], + 'S_RGB_LIGHT': [LIGHT_RGB_SCHEMA], + 'S_RGBW_LIGHT': [LIGHT_RGBW_SCHEMA], + 'S_INFO': [NOTIFY_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_TEXT'}], + 'S_GPS': [ + DEVICE_TRACKER_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_POSITION'}], + 'S_TEMP': [{PLATFORM: 'sensor', TYPE: 'V_TEMP'}], + 'S_HUM': [{PLATFORM: 'sensor', TYPE: 'V_HUM'}], + 'S_BARO': [ + {PLATFORM: 'sensor', TYPE: 'V_PRESSURE'}, + {PLATFORM: 'sensor', TYPE: 'V_FORECAST'}], + 'S_WIND': [ + {PLATFORM: 'sensor', TYPE: 'V_WIND'}, + {PLATFORM: 'sensor', TYPE: 'V_GUST'}, + {PLATFORM: 'sensor', TYPE: 'V_DIRECTION'}], + 'S_RAIN': [ + {PLATFORM: 'sensor', TYPE: 'V_RAIN'}, + {PLATFORM: 'sensor', TYPE: 'V_RAINRATE'}], + 'S_UV': [{PLATFORM: 'sensor', TYPE: 'V_UV'}], + 'S_WEIGHT': [ + {PLATFORM: 'sensor', TYPE: 'V_WEIGHT'}, + {PLATFORM: 'sensor', TYPE: 'V_IMPEDANCE'}], + 'S_POWER': [ + {PLATFORM: 'sensor', TYPE: 'V_WATT'}, + {PLATFORM: 'sensor', TYPE: 'V_KWH'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR'}, + {PLATFORM: 'sensor', TYPE: 'V_VA'}, + {PLATFORM: 'sensor', TYPE: 'V_POWER_FACTOR'}], + 'S_DISTANCE': [{PLATFORM: 'sensor', TYPE: 'V_DISTANCE'}], + 'S_LIGHT_LEVEL': [ + {PLATFORM: 'sensor', TYPE: 'V_LIGHT_LEVEL'}, + {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}], + 'S_IR': [ + {PLATFORM: 'sensor', TYPE: 'V_IR_RECEIVE'}, + {PLATFORM: 'switch', TYPE: 'V_IR_SEND', + SCHEMA: {'V_IR_SEND': cv.string, 'V_LIGHT': cv.string}}], + 'S_WATER': [ + {PLATFORM: 'sensor', TYPE: 'V_FLOW'}, + {PLATFORM: 'sensor', TYPE: 'V_VOLUME'}], + 'S_CUSTOM': [ + {PLATFORM: 'sensor', TYPE: 'V_VAR1'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR2'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR3'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR4'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR5'}, + {PLATFORM: 'sensor', TYPE: 'V_CUSTOM'}], + 'S_SCENE_CONTROLLER': [ + {PLATFORM: 'sensor', TYPE: 'V_SCENE_ON'}, + {PLATFORM: 'sensor', TYPE: 'V_SCENE_OFF'}], + 'S_COLOR_SENSOR': [{PLATFORM: 'sensor', TYPE: 'V_RGB'}], + 'S_MULTIMETER': [ + {PLATFORM: 'sensor', TYPE: 'V_VOLTAGE'}, + {PLATFORM: 'sensor', TYPE: 'V_CURRENT'}, + {PLATFORM: 'sensor', TYPE: 'V_IMPEDANCE'}], + 'S_GAS': [ + {PLATFORM: 'sensor', TYPE: 'V_FLOW'}, + {PLATFORM: 'sensor', TYPE: 'V_VOLUME'}], + 'S_WATER_QUALITY': [ + {PLATFORM: 'sensor', TYPE: 'V_TEMP'}, + {PLATFORM: 'sensor', TYPE: 'V_PH'}, + {PLATFORM: 'sensor', TYPE: 'V_ORP'}, + {PLATFORM: 'sensor', TYPE: 'V_EC'}, + {PLATFORM: 'switch', TYPE: 'V_STATUS'}], + 'S_AIR_QUALITY': DUST_SCHEMA, + 'S_DUST': DUST_SCHEMA, + 'S_LIGHT': [SWITCH_LIGHT_SCHEMA], + 'S_BINARY': [SWITCH_STATUS_SCHEMA], + 'S_LOCK': [{PLATFORM: 'switch', TYPE: 'V_LOCK_STATUS'}], +} + + def setup(hass, config): """Set up the MySensors component.""" import mysensors.mysensors as mysensors @@ -197,20 +335,14 @@ def sub_callback(topic, callback, qos): # invalid ip address return gateway.metric = hass.config.units.is_metric - optimistic = config[DOMAIN].get(CONF_OPTIMISTIC) - gateway = GatewayWrapper(gateway, optimistic, device) - # pylint: disable=attribute-defined-outside-init - gateway.event_callback = gateway.callback_factory() + gateway.optimistic = config[DOMAIN].get(CONF_OPTIMISTIC) + gateway.device = device + gateway.event_callback = gw_callback_factory(hass) def gw_start(event): """Trigger to start of the gateway and any persistence.""" if persistence: - for node_id in gateway.sensors: - node = gateway.sensors[node_id] - for child_id in node.children: - msg = mysensors.Message().modify( - node_id=node_id, child_id=child_id) - gateway.event_callback(msg) + discover_persistent_devices(hass, gateway) gateway.start() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, lambda event: gateway.stop()) @@ -219,15 +351,8 @@ def gw_start(event): return gateway - gateways = hass.data.get(MYSENSORS_GATEWAYS) - if gateways is not None: - _LOGGER.error( - "%s already exists in %s, will not setup %s component", - MYSENSORS_GATEWAYS, hass.data, DOMAIN) - return False - # Setup all devices from config - gateways = [] + gateways = {} conf_gateways = config[DOMAIN][CONF_GATEWAYS] for index, gway in enumerate(conf_gateways): @@ -243,7 +368,8 @@ def gw_start(event): device, persistence_file, baud_rate, tcp_port, in_prefix, out_prefix) if ready_gateway is not None: - gateways.append(ready_gateway) + ready_gateway.nodes_config = gway.get(CONF_NODES) + gateways[id(ready_gateway)] = ready_gateway if not gateways: _LOGGER.error( @@ -252,115 +378,194 @@ def gw_start(event): hass.data[MYSENSORS_GATEWAYS] = gateways - for component in ['sensor', 'switch', 'light', 'binary_sensor', 'climate', - 'cover']: - discovery.load_platform(hass, component, DOMAIN, {}, config) - - discovery.load_platform( - hass, 'device_tracker', DOMAIN, {}, config) - - discovery.load_platform( - hass, 'notify', DOMAIN, {CONF_NAME: DOMAIN}, config) - return True -def pf_callback_factory(map_sv_types, devices, entity_class, add_devices=None): - """Return a new callback for the platform.""" - def mysensors_callback(gateway, msg): - """Run when a message from the gateway arrives.""" - if gateway.sensors[msg.node_id].sketch_name is None: - _LOGGER.debug("No sketch_name: node %s", msg.node_id) - return - child = gateway.sensors[msg.node_id].children.get(msg.child_id) +def validate_child(gateway, node_id, child): + """Validate that a child has the correct values according to schema. + + Return a dict of platform with a list of device ids for validated devices. + """ + validated = defaultdict(list) + + if not child.values: + _LOGGER.debug( + "No child values for node %s child %s", node_id, child.id) + return validated + if gateway.sensors[node_id].sketch_name is None: + _LOGGER.debug("Node %s is missing sketch name", node_id) + return validated + pres = gateway.const.Presentation + set_req = gateway.const.SetReq + s_name = next( + (member.name for member in pres if member.value == child.type), None) + if s_name not in MYSENSORS_CONST_SCHEMA: + _LOGGER.warning("Child type %s is not supported", s_name) + return validated + child_schemas = MYSENSORS_CONST_SCHEMA[s_name] + + def msg(name): + """Return a message for an invalid schema.""" + return "{} requires value_type {}".format( + pres(child.type).name, set_req[name].name) + + for schema in child_schemas: + platform = schema[PLATFORM] + v_name = schema[TYPE] + value_type = next( + (member.value for member in set_req if member.name == v_name), + None) + if value_type is None: + continue + _child_schema = child.get_schema(gateway.protocol_version) + vol_schema = _child_schema.extend( + {vol.Required(set_req[key].value, msg=msg(key)): + _child_schema.schema.get(set_req[key].value, val) + for key, val in schema.get(SCHEMA, {v_name: cv.string}).items()}, + extra=vol.ALLOW_EXTRA) + try: + vol_schema(child.values) + except vol.Invalid as exc: + level = (logging.WARNING if value_type in child.values + else logging.DEBUG) + _LOGGER.log( + level, + "Invalid values: %s: %s platform: node %s child %s: %s", + child.values, platform, node_id, child.id, exc) + continue + dev_id = id(gateway), node_id, child.id, value_type + validated[platform].append(dev_id) + return validated + + +def discover_mysensors_platform(hass, platform, new_devices): + """Discover a mysensors platform.""" + discovery.load_platform( + hass, platform, DOMAIN, {ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN}) + + +def discover_persistent_devices(hass, gateway): + """Discover platforms for devices loaded via persistence file.""" + new_devices = defaultdict(list) + for node_id in gateway.sensors: + node = gateway.sensors[node_id] + for child in node.children.values(): + validated = validate_child(gateway, node_id, child) + for platform, dev_ids in validated.items(): + new_devices[platform].extend(dev_ids) + for platform, dev_ids in new_devices.items(): + discover_mysensors_platform(hass, platform, dev_ids) + + +def get_mysensors_devices(hass, domain): + """Return mysensors devices for a platform.""" + if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data: + hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} + return hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] + + +def gw_callback_factory(hass): + """Return a new callback for the gateway.""" + def mysensors_callback(msg): + """Default callback for a mysensors gateway.""" + start = timer() + _LOGGER.debug( + "Node update: node %s child %s", msg.node_id, msg.child_id) + + child = msg.gateway.sensors[msg.node_id].children.get(msg.child_id) if child is None: + _LOGGER.debug( + "Not a child update for node %s", msg.node_id) return - for value_type in child.values: - key = msg.node_id, child.id, value_type - if child.type not in map_sv_types or \ - value_type not in map_sv_types[child.type]: - continue - if key in devices: - if add_devices: - devices[key].schedule_update_ha_state(True) + + signals = [] + + # Update all platforms for the device via dispatcher. + # Add/update entity if schema validates to true. + validated = validate_child(msg.gateway, msg.node_id, child) + for platform, dev_ids in validated.items(): + devices = get_mysensors_devices(hass, platform) + new_dev_ids = [] + for dev_id in dev_ids: + if dev_id in devices: + signals.append(SIGNAL_CALLBACK.format(*dev_id)) else: - devices[key].update() - continue - name = '{} {} {}'.format( - gateway.sensors[msg.node_id].sketch_name, msg.node_id, - child.id) - if isinstance(entity_class, dict): - device_class = entity_class[child.type] - else: - device_class = entity_class - devices[key] = device_class( - gateway, msg.node_id, child.id, name, value_type) - if add_devices: - _LOGGER.info("Adding new devices: %s", [devices[key]]) - add_devices([devices[key]], True) - else: - devices[key].update() + new_dev_ids.append(dev_id) + if new_dev_ids: + discover_mysensors_platform(hass, platform, new_dev_ids) + for signal in set(signals): + # Only one signal per device is needed. + # A device can have multiple platforms, ie multiple schemas. + # FOR LATER: Add timer to not signal if another update comes in. + dispatcher_send(hass, signal) + end = timer() + if end - start > 0.1: + _LOGGER.debug( + "Callback for node %s child %s took %.3f seconds", + msg.node_id, msg.child_id, end - start) return mysensors_callback -class GatewayWrapper(object): - """Gateway wrapper class.""" - - def __init__(self, gateway, optimistic, device): - """Set up the class attributes on instantiation. - - Args: - gateway (mysensors.SerialGateway): Gateway to wrap. - optimistic (bool): Send values to actuators without feedback state. - device (str): Path to serial port, ip adress or mqtt. - - Attributes: - _wrapped_gateway (mysensors.SerialGateway): Wrapped gateway. - platform_callbacks (list): Callback functions, one per platform. - optimistic (bool): Send values to actuators without feedback state. - device (str): Device configured as gateway. - __initialised (bool): True if GatewayWrapper is initialised. - - """ - self._wrapped_gateway = gateway - self.platform_callbacks = [] - self.optimistic = optimistic - self.device = device - self.__initialised = True - - def __getattr__(self, name): - """See if this object has attribute name.""" - # Do not use hasattr, it goes into infinite recurrsion - if name in self.__dict__: - # This object has the attribute. - return getattr(self, name) - # The wrapped object has the attribute. - return getattr(self._wrapped_gateway, name) - - def __setattr__(self, name, value): - """See if this object has attribute name then set to value.""" - if '_GatewayWrapper__initialised' not in self.__dict__: - return object.__setattr__(self, name, value) - elif name in self.__dict__: - object.__setattr__(self, name, value) - else: - object.__setattr__(self._wrapped_gateway, name, value) - - def callback_factory(self): - """Return a new callback function.""" - def node_update(msg): - """Handle node updates from the MySensors gateway.""" - _LOGGER.debug( - "Update: node %s, child %s sub_type %s", - msg.node_id, msg.child_id, msg.sub_type) - for callback in self.platform_callbacks: - callback(self, msg) - - return node_update +def get_mysensors_name(gateway, node_id, child_id): + """Return a name for a node child.""" + node_name = '{} {}'.format( + gateway.sensors[node_id].sketch_name, node_id) + node_name = next( + (node[CONF_NODE_NAME] for conf_id, node in gateway.nodes_config.items() + if node.get(CONF_NODE_NAME) is not None and conf_id == node_id), + node_name) + return '{} {}'.format(node_name, child_id) -class MySensorsDeviceEntity(object): - """Representation of a MySensors entity.""" +def get_mysensors_gateway(hass, gateway_id): + """Return gateway.""" + if MYSENSORS_GATEWAYS not in hass.data: + hass.data[MYSENSORS_GATEWAYS] = {} + gateways = hass.data.get(MYSENSORS_GATEWAYS) + return gateways.get(gateway_id) + + +def setup_mysensors_platform( + hass, domain, discovery_info, device_class, device_args=None, + add_devices=None): + """Set up a mysensors platform.""" + # Only act if called via mysensors by discovery event. + # Otherwise gateway is not setup. + if not discovery_info: + return + if device_args is None: + device_args = () + new_devices = [] + new_dev_ids = discovery_info[ATTR_DEVICES] + for dev_id in new_dev_ids: + devices = get_mysensors_devices(hass, domain) + if dev_id in devices: + continue + gateway_id, node_id, child_id, value_type = dev_id + gateway = get_mysensors_gateway(hass, gateway_id) + if not gateway: + continue + device_class_copy = device_class + if isinstance(device_class, dict): + child = gateway.sensors[node_id].children[child_id] + s_type = gateway.const.Presentation(child.type).name + device_class_copy = device_class[s_type] + name = get_mysensors_name(gateway, node_id, child_id) + + # python 3.4 cannot unpack inside tuple, but combining tuples works + args_copy = device_args + ( + gateway, node_id, child_id, name, value_type) + devices[dev_id] = device_class_copy(*args_copy) + new_devices.append(devices[dev_id]) + if new_devices: + _LOGGER.info("Adding new devices: %s", new_devices) + if add_devices is not None: + add_devices(new_devices, True) + return new_devices + + +class MySensorsDevice(object): + """Representation of a MySensors device.""" def __init__(self, gateway, node_id, child_id, name, value_type): """Set up the MySensors device.""" @@ -373,11 +578,6 @@ def __init__(self, gateway, node_id, child_id, name, value_type): self.child_type = child.type self._values = {} - @property - def should_poll(self): - """Mysensor gateway pushes its state to HA.""" - return False - @property def name(self): """Return the name of this entity.""" @@ -399,18 +599,9 @@ def device_state_attributes(self): set_req = self.gateway.const.SetReq for value_type, value in self._values.items(): - try: - attr[set_req(value_type).name] = value - except ValueError: - _LOGGER.error("Value_type %s is not valid for mysensors " - "version %s", value_type, - self.gateway.protocol_version) - return attr + attr[set_req(value_type).name] = value - @property - def available(self): - """Return true if entity is available.""" - return self.value_type in self._values + return attr def update(self): """Update the controller with the latest value from a sensor.""" @@ -419,7 +610,8 @@ def update(self): set_req = self.gateway.const.SetReq for value_type, value in child.values.items(): _LOGGER.debug( - "%s: value_type %s, value = %s", self._name, value_type, value) + "Entity update: %s: value_type %s, value = %s", + self._name, value_type, value) if value_type in (set_req.V_ARMED, set_req.V_LIGHT, set_req.V_LOCK_STATUS, set_req.V_TRIPPED): self._values[value_type] = ( @@ -428,3 +620,29 @@ def update(self): self._values[value_type] = int(value) else: self._values[value_type] = value + + +class MySensorsEntity(MySensorsDevice, Entity): + """Representation of a MySensors entity.""" + + @property + def should_poll(self): + """Mysensor gateway pushes its state to HA.""" + return False + + @property + def available(self): + """Return true if entity is available.""" + return self.value_type in self._values + + def _async_update_callback(self): + """Update the entity.""" + self.hass.async_add_job(self.async_update_ha_state(True)) + + @asyncio.coroutine + def async_added_to_hass(self): + """Register update callback.""" + dev_id = id(self.gateway), self.node_id, self.child_id, self.value_type + async_dispatcher_connect( + self.hass, SIGNAL_CALLBACK.format(*dev_id), + self._async_update_callback) diff --git a/homeassistant/components/nest.py b/homeassistant/components/nest.py index 6443fc47a8501..512819b7e743d 100644 --- a/homeassistant/components/nest.py +++ b/homeassistant/components/nest.py @@ -14,7 +14,6 @@ from homeassistant.const import ( CONF_STRUCTURE, CONF_FILENAME, CONF_BINARY_SENSORS, CONF_SENSORS, CONF_MONITORED_CONDITIONS) -from homeassistant.loader import get_component REQUIREMENTS = ['python-nest==3.1.0'] @@ -54,7 +53,7 @@ def request_configuration(nest, hass, config): """Request configuration steps from the user.""" - configurator = get_component('configurator') + configurator = hass.components.configurator if 'nest' in _CONFIGURING: _LOGGER.debug("configurator failed") configurator.notify_errors( @@ -68,7 +67,7 @@ def nest_configuration_callback(data): setup_nest(hass, nest, config, pin=pin) _CONFIGURING['nest'] = configurator.request_config( - hass, "Nest", nest_configuration_callback, + "Nest", nest_configuration_callback, description=('To configure Nest, click Request Authorization below, ' 'log into your Nest account, ' 'and then enter the resulting PIN'), @@ -92,7 +91,7 @@ def setup_nest(hass, nest, config, pin=None): if 'nest' in _CONFIGURING: _LOGGER.debug("configuration done") - configurator = get_component('configurator') + configurator = hass.components.configurator configurator.request_done(_CONFIGURING.pop('nest')) _LOGGER.debug("proceeding with setup") diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 1c17d1a795ad8..9496ff1d596ea 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -82,8 +82,6 @@ def async_setup_platform(p_type, p_config=None, discovery_info=None): """Set up a notify platform.""" if p_config is None: p_config = {} - if discovery_info is None: - discovery_info = {} platform = yield from async_prepare_setup_platform( hass, config, DOMAIN, p_type) @@ -105,8 +103,12 @@ def async_setup_platform(p_type, p_config=None, discovery_info=None): raise HomeAssistantError("Invalid notify platform.") if notify_service is None: - _LOGGER.error( - "Failed to initialize notification service %s", p_type) + # Platforms can decide not to create a service based + # on discovery data. + if discovery_info is None: + _LOGGER.error( + "Failed to initialize notification service %s", + p_type) return except Exception: # pylint: disable=broad-except @@ -115,6 +117,9 @@ def async_setup_platform(p_type, p_config=None, discovery_info=None): notify_service.hass = hass + if discovery_info is None: + discovery_info = {} + @asyncio.coroutine def async_notify_message(service): """Handle sending notification message service calls.""" diff --git a/homeassistant/components/notify/discord.py b/homeassistant/components/notify/discord.py index 691ff158012ef..90212bca025f5 100644 --- a/homeassistant/components/notify/discord.py +++ b/homeassistant/components/notify/discord.py @@ -4,16 +4,18 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.discord/ """ -import logging import asyncio +import logging + import voluptuous as vol + import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ATTR_TARGET) _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['discord.py==0.16.8'] +REQUIREMENTS = ['discord.py==0.16.11'] CONF_TOKEN = 'token' @@ -42,13 +44,22 @@ def async_send_message(self, message, **kwargs): import discord discord_bot = discord.Client(loop=self.hass.loop) + if ATTR_TARGET not in kwargs: + _LOGGER.error("No target specified") + return None + + # pylint: disable=unused-variable @discord_bot.event @asyncio.coroutine - def on_ready(): # pylint: disable=unused-variable + def on_ready(): """Send the messages when the bot is ready.""" - for channelid in kwargs[ATTR_TARGET]: - channel = discord.Object(id=channelid) - yield from discord_bot.send_message(channel, message) + try: + for channelid in kwargs[ATTR_TARGET]: + channel = discord.Object(id=channelid) + yield from discord_bot.send_message(channel, message) + except (discord.errors.HTTPException, + discord.errors.NotFound) as error: + _LOGGER.warning("Communication error: %s", error) yield from discord_bot.logout() yield from discord_bot.close() diff --git a/homeassistant/components/notify/hipchat.py b/homeassistant/components/notify/hipchat.py new file mode 100644 index 0000000000000..ee1283b982046 --- /dev/null +++ b/homeassistant/components/notify/hipchat.py @@ -0,0 +1,97 @@ +""" +HipChat platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.hipchat/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.notify import ( + ATTR_TARGET, ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONF_TOKEN, CONF_HOST + +REQUIREMENTS = ['hipnotify==1.0.8'] + +_LOGGER = logging.getLogger(__name__) + +CONF_COLOR = 'color' +CONF_ROOM = 'room' +CONF_NOTIFY = 'notify' +CONF_FORMAT = 'format' + +DEFAULT_COLOR = 'yellow' +DEFAULT_FORMAT = 'text' +DEFAULT_HOST = 'https://api.hipchat.com/' +DEFAULT_NOTIFY = False + +VALID_COLORS = {'yellow', 'green', 'red', 'purple', 'gray', 'random'} +VALID_FORMATS = {'text', 'html'} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ROOM): vol.Coerce(int), + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_COLOR, default=DEFAULT_COLOR): vol.In(VALID_COLORS), + vol.Optional(CONF_FORMAT, default=DEFAULT_FORMAT): vol.In(VALID_FORMATS), + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NOTIFY, default=DEFAULT_NOTIFY): cv.boolean, +}) + + +def get_service(hass, config, discovery_info=None): + """Get the HipChat notification service.""" + return HipchatNotificationService( + config[CONF_TOKEN], config[CONF_ROOM], config[CONF_COLOR], + config[CONF_NOTIFY], config[CONF_FORMAT], config[CONF_HOST]) + + +class HipchatNotificationService(BaseNotificationService): + """Implement the notification service for HipChat.""" + + def __init__(self, token, default_room, default_color, default_notify, + default_format, host): + """Initialize the service.""" + self._token = token + self._default_room = default_room + self._default_color = default_color + self._default_notify = default_notify + self._default_format = default_format + self._host = host + + self._rooms = {} + self._get_room(self._default_room) + + def _get_room(self, room): + """Get Room object, creating it if necessary.""" + from hipnotify import Room + if room not in self._rooms: + self._rooms[room] = Room( + token=self._token, room_id=room, endpoint_url=self._host) + return self._rooms[room] + + def send_message(self, message="", **kwargs): + """Send a message.""" + color = self._default_color + notify = self._default_notify + message_format = self._default_format + + if kwargs.get(ATTR_DATA) is not None: + data = kwargs.get(ATTR_DATA) + if ((data.get(CONF_COLOR) is not None) + and (data.get(CONF_COLOR) in VALID_COLORS)): + color = data.get(CONF_COLOR) + if ((data.get(CONF_NOTIFY) is not None) + and isinstance(data.get(CONF_NOTIFY), bool)): + notify = data.get(CONF_NOTIFY) + if ((data.get(CONF_FORMAT) is not None) + and (data.get(CONF_FORMAT) in VALID_FORMATS)): + message_format = data.get(CONF_FORMAT) + + targets = kwargs.get(ATTR_TARGET, [self._default_room]) + + for target in targets: + room = self._get_room(target) + room.notify(msg=message, color=color, notify=notify, + message_format=message_format) diff --git a/homeassistant/components/notify/knx.py b/homeassistant/components/notify/knx.py new file mode 100644 index 0000000000000..c5dbcb0d4ad3e --- /dev/null +++ b/homeassistant/components/notify/knx.py @@ -0,0 +1,99 @@ +""" +KNX/IP notification service. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/notify.knx/ +""" +import asyncio +import voluptuous as vol + +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.notify import PLATFORM_SCHEMA, \ + BaseNotificationService +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +CONF_ADDRESS = 'address' +DEFAULT_NAME = 'KNX Notify' +DEPENDENCIES = ['knx'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +@asyncio.coroutine +def async_get_service(hass, config, discovery_info=None): + """Get the KNX notification service.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + return async_get_service_discovery(hass, discovery_info) \ + if discovery_info is not None else \ + async_get_service_config(hass, config) + + +@callback +def async_get_service_discovery(hass, discovery_info): + """Set up notifications for KNX platform configured via xknx.yaml.""" + notification_devices = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + notification_devices.append(device) + return \ + KNXNotificationService(hass, notification_devices) \ + if notification_devices else \ + None + + +@callback +def async_get_service_config(hass, config): + """Set up notification for KNX platform configured within plattform.""" + import xknx + notification = xknx.devices.Notification( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address=config.get(CONF_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(notification) + return KNXNotificationService(hass, [notification, ]) + + +class KNXNotificationService(BaseNotificationService): + """Implement demo notification service.""" + + def __init__(self, hass, devices): + """Initialize the service.""" + self.hass = hass + self.devices = devices + + @property + def targets(self): + """Return a dictionary of registered targets.""" + ret = {} + for device in self.devices: + ret[device.name] = device.name + return ret + + @asyncio.coroutine + def async_send_message(self, message="", **kwargs): + """Send a notification to knx bus.""" + if "target" in kwargs: + yield from self._async_send_to_device(message, kwargs["target"]) + else: + yield from self._async_send_to_all_devices(message) + + @asyncio.coroutine + def _async_send_to_all_devices(self, message): + """Send a notification to knx bus to all connected devices.""" + for device in self.devices: + yield from device.set(message) + + @asyncio.coroutine + def _async_send_to_device(self, message, names): + """Send a notification to knx bus to device with given names.""" + for device in self.devices: + if device.name in names: + yield from device.set(message) diff --git a/homeassistant/components/notify/mycroft.py b/homeassistant/components/notify/mycroft.py new file mode 100644 index 0000000000000..1fd22c5c42b5c --- /dev/null +++ b/homeassistant/components/notify/mycroft.py @@ -0,0 +1,40 @@ +""" +Mycroft AI notification platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.mycroft/ +""" +import logging + + +from homeassistant.components.notify import BaseNotificationService + +DEPENDENCIES = ['mycroft'] + + +_LOGGER = logging.getLogger(__name__) + + +def get_service(hass, config, discovery_info=None): + """Get the Mycroft notification service.""" + return MycroftNotificationService( + hass.data['mycroft']) + + +class MycroftNotificationService(BaseNotificationService): + """The Mycroft Notification Service.""" + + def __init__(self, mycroft_ip): + """Initialize the service.""" + self.mycroft_ip = mycroft_ip + + def send_message(self, message="", **kwargs): + """Send a message mycroft to speak on instance.""" + from mycroftapi import MycroftAPI + + text = message + mycroft = MycroftAPI(self.mycroft_ip) + if mycroft is not None: + mycroft.speak_text(text) + else: + _LOGGER.log("Could not reach this instance of mycroft") diff --git a/homeassistant/components/notify/mysensors.py b/homeassistant/components/notify/mysensors.py index d9576767f25ab..8ae697048f501 100644 --- a/homeassistant/components/notify/mysensors.py +++ b/homeassistant/components/notify/mysensors.py @@ -6,35 +6,19 @@ """ from homeassistant.components import mysensors from homeassistant.components.notify import ( - ATTR_TARGET, BaseNotificationService) + ATTR_TARGET, DOMAIN, BaseNotificationService) def get_service(hass, config, discovery_info=None): """Get the MySensors notification service.""" - if discovery_info is None: + new_devices = mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsNotificationDevice) + if not new_devices: return - platform_devices = [] - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - if float(gateway.protocol_version) < 2.0: - continue - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_INFO: [set_req.V_TEXT], - } - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, MySensorsNotificationDevice)) - platform_devices.append(devices) + return MySensorsNotificationService(hass) - return MySensorsNotificationService(platform_devices) - -class MySensorsNotificationDevice(mysensors.MySensorsDeviceEntity): +class MySensorsNotificationDevice(mysensors.MySensorsDevice): """Represent a MySensors Notification device.""" def send_msg(self, msg): @@ -44,24 +28,25 @@ def send_msg(self, msg): self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, sub_msg) + def __repr__(self): + """Return the representation.""" + return "".format(self.name) + class MySensorsNotificationService(BaseNotificationService): - """Implement MySensors notification service.""" + """Implement a MySensors notification service.""" # pylint: disable=too-few-public-methods - def __init__(self, platform_devices): + def __init__(self, hass): """Initialize the service.""" - self.platform_devices = platform_devices + self.devices = mysensors.get_mysensors_devices(hass, DOMAIN) def send_message(self, message="", **kwargs): """Send a message to a user.""" target_devices = kwargs.get(ATTR_TARGET) - devices = [] - for gw_devs in self.platform_devices: - for device in gw_devs.values(): - if target_devices is None or device.name in target_devices: - devices.append(device) + devices = [device for device in self.devices.values() + if target_devices is None or device.name in target_devices] for device in devices: device.send_msg(message) diff --git a/homeassistant/components/notify/prowl.py b/homeassistant/components/notify/prowl.py new file mode 100644 index 0000000000000..1298657a69a44 --- /dev/null +++ b/homeassistant/components/notify/prowl.py @@ -0,0 +1,70 @@ +""" +Prowl notification service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.prowl/ +""" +import logging +import asyncio + +import async_timeout +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_DATA, PLATFORM_SCHEMA, + BaseNotificationService) +from homeassistant.const import CONF_API_KEY +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER = logging.getLogger(__name__) +_RESOURCE = 'https://api.prowlapp.com/publicapi/' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, +}) + + +@asyncio.coroutine +def async_get_service(hass, config, discovery_info=None): + """Get the Prowl notification service.""" + return ProwlNotificationService(hass, config[CONF_API_KEY]) + + +class ProwlNotificationService(BaseNotificationService): + """Implement the notification service for Prowl.""" + + def __init__(self, hass, api_key): + """Initialize the service.""" + self._hass = hass + self._api_key = api_key + + @asyncio.coroutine + def async_send_message(self, message, **kwargs): + """Send the message to the user.""" + response = None + session = None + url = '{}{}'.format(_RESOURCE, 'add') + data = kwargs.get(ATTR_DATA) + payload = { + 'apikey': self._api_key, + 'application': 'Home-Assistant', + 'event': kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), + 'description': message, + 'priority': data['priority'] if data and 'priority' in data else 0 + } + + _LOGGER.debug("Attempting call Prowl service at %s", url) + session = async_get_clientsession(self._hass) + + try: + with async_timeout.timeout(10, loop=self._hass.loop): + response = yield from session.post(url, data=payload) + result = yield from response.text() + + if response.status != 200 or 'error' in result: + _LOGGER.error("Prowl service returned http " + "status %d, response %s", + response.status, result) + except asyncio.TimeoutError: + _LOGGER.error("Timeout accessing Prowl at %s", url) diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index 8ac2bd06dad01..d8b6741352845 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -5,6 +5,7 @@ https://home-assistant.io/components/notify.pushbullet/ """ import logging +import mimetypes import voluptuous as vol @@ -20,6 +21,7 @@ ATTR_URL = 'url' ATTR_FILE = 'file' +ATTR_FILE_URL = 'file_url' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, @@ -80,24 +82,11 @@ def send_message(self, message=None, **kwargs): targets = kwargs.get(ATTR_TARGET) title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = kwargs.get(ATTR_DATA) - url = None - filepath = None - if data: - url = data.get(ATTR_URL, None) - filepath = data.get(ATTR_FILE, None) refreshed = False if not targets: # Backward compatibility, notify all devices in own account - if url: - self.pushbullet.push_link(title, url, body=message) - if filepath and self.hass.config.is_allowed_path(filepath): - with open(filepath, "rb") as fileh: - filedata = self.pushbullet.upload_file(fileh, filepath) - self.pushbullet.push_file(title=title, body=message, - **filedata) - else: - self.pushbullet.push_note(title, message) + self._push_data(message, title, data, self.pushbullet) _LOGGER.info("Sent notification to self") return @@ -112,16 +101,7 @@ def send_message(self, message=None, **kwargs): # Target is email, send directly, don't use a target object # This also seems works to send to all devices in own account if ttype == 'email': - if url: - self.pushbullet.push_link( - title, url, body=message, email=tname) - if filepath and self.hass.config.is_allowed_path(filepath): - with open(filepath, "rb") as fileh: - filedata = self.pushbullet.upload_file(fileh, filepath) - self.pushbullet.push_file(title=title, body=message, - **filedata) - else: - self.pushbullet.push_note(title, message, email=tname) + self._push_data(message, title, data, self.pushbullet, tname) _LOGGER.info("Sent notification to email %s", tname) continue @@ -140,15 +120,47 @@ def send_message(self, message=None, **kwargs): # Attempt push_note on a dict value. Keys are types & target # name. Dict pbtargets has all *actual* targets. try: - if url: - self.pbtargets[ttype][tname].push_link( - title, url, body=message) - else: - self.pbtargets[ttype][tname].push_note(title, message) + self._push_data(message, title, data, + self.pbtargets[ttype][tname]) _LOGGER.info("Sent notification to %s/%s", ttype, tname) except KeyError: _LOGGER.error("No such target: %s/%s", ttype, tname) continue - except self.pushbullet.errors.PushError: - _LOGGER.error("Notify failed to: %s/%s", ttype, tname) - continue + + def _push_data(self, message, title, data, pusher, tname=None): + from pushbullet import PushError + if data is None: + data = {} + url = data.get(ATTR_URL) + filepath = data.get(ATTR_FILE) + file_url = data.get(ATTR_FILE_URL) + try: + if url: + if tname: + pusher.push_link(title, url, body=message, email=tname) + else: + pusher.push_link(title, url, body=message) + elif filepath: + if not self.hass.config.is_allowed_path(filepath): + _LOGGER.error("Filepath is not valid or allowed.") + return + with open(filepath, "rb") as fileh: + filedata = self.pushbullet.upload_file(fileh, filepath) + if filedata.get('file_type') == 'application/x-empty': + _LOGGER.error("Can not send an empty file.") + return + pusher.push_file(title=title, body=message, **filedata) + elif file_url: + if not file_url.startswith('http'): + _LOGGER.error("Url should start with http or https.") + return + pusher.push_file(title=title, body=message, file_name=file_url, + file_url=file_url, + file_type=mimetypes.guess_type(file_url)[0]) + else: + if tname: + pusher.push_note(title, message, email=tname) + else: + pusher.push_note(title, message) + except PushError as err: + _LOGGER.error("Notify failed: %s", err) diff --git a/homeassistant/components/notify/pushover.py b/homeassistant/components/notify/pushover.py index 3d8d62230ee4c..cd73bbba4bfe8 100644 --- a/homeassistant/components/notify/pushover.py +++ b/homeassistant/components/notify/pushover.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_API_KEY import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-pushover==0.2'] +REQUIREMENTS = ['python-pushover==0.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index 038c7cd8ee9ea..b7f192ff9834a 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -13,7 +13,7 @@ from homeassistant.const import (CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['sendgrid==4.2.1'] +REQUIREMENTS = ['sendgrid==5.2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index a6257970566c7..30aadfc8297c9 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -5,20 +5,19 @@ https://home-assistant.io/components/notify.slack/ """ import logging + import requests -from requests.auth import HTTPDigestAuth from requests.auth import HTTPBasicAuth - +from requests.auth import HTTPDigestAuth import voluptuous as vol -from homeassistant.components.notify import ( - ATTR_TARGET, ATTR_TITLE, ATTR_DATA, - PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import ( - CONF_API_KEY, CONF_USERNAME, CONF_ICON) import homeassistant.helpers.config_validation as cv +from homeassistant.components.notify import ( + ATTR_TARGET, ATTR_TITLE, ATTR_DATA, PLATFORM_SCHEMA, + BaseNotificationService) +from homeassistant.const import (CONF_API_KEY, CONF_USERNAME, CONF_ICON) -REQUIREMENTS = ['slacker==0.9.50'] +REQUIREMENTS = ['slacker==0.9.60'] _LOGGER = logging.getLogger(__name__) @@ -34,7 +33,7 @@ ATTR_FILE_USERNAME = 'username' ATTR_FILE_PASSWORD = 'password' ATTR_FILE_AUTH = 'auth' -# Any other value or absense of 'auth' lead to basic authentication being used +# Any other value or absence of 'auth' lead to basic authentication being used ATTR_FILE_AUTH_DIGEST = 'digest' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -49,14 +48,14 @@ def get_service(hass, config, discovery_info=None): """Get the Slack notification service.""" import slacker + channel = config.get(CONF_CHANNEL) + api_key = config.get(CONF_API_KEY) + username = config.get(CONF_USERNAME) + icon = config.get(CONF_ICON) try: return SlackNotificationService( - config[CONF_CHANNEL], - config[CONF_API_KEY], - config.get(CONF_USERNAME, None), - config.get(CONF_ICON, None), - hass.config.is_allowed_path) + channel, api_key, username, icon, hass.config.is_allowed_path) except slacker.Error: _LOGGER.exception("Authentication failed") @@ -66,9 +65,8 @@ def get_service(hass, config, discovery_info=None): class SlackNotificationService(BaseNotificationService): """Implement the notification service for Slack.""" - def __init__(self, default_channel, - api_token, username, - icon, is_allowed_path): + def __init__( + self, default_channel, api_token, username, icon, is_allowed_path): """Initialize the service.""" from slacker import Slacker self._default_channel = default_channel @@ -101,7 +99,7 @@ def send_message(self, message="", **kwargs): for target in targets: try: if file is not None: - # Load from file or url + # Load from file or URL file_as_bytes = self.load_file( url=file.get(ATTR_FILE_URL), local_path=file.get(ATTR_FILE_PATH), @@ -113,7 +111,7 @@ def send_message(self, message="", **kwargs): filename = file.get(ATTR_FILE_URL) else: filename = file.get(ATTR_FILE_PATH) - # Prepare structure for slack API + # Prepare structure for Slack API data = { 'content': None, 'filetype': None, @@ -135,35 +133,33 @@ def send_message(self, message="", **kwargs): except slacker.Error as err: _LOGGER.error("Could not send notification. Error: %s", err) - def load_file(self, url=None, local_path=None, - username=None, password=None, auth=None): - """Load image/document/etc from a local path or url.""" + def load_file(self, url=None, local_path=None, username=None, + password=None, auth=None): + """Load image/document/etc from a local path or URL.""" try: if url is not None: - # check whether authentication parameters are provided + # Check whether authentication parameters are provided if username is not None and password is not None: # Use digest or basic authentication if ATTR_FILE_AUTH_DIGEST == auth: auth_ = HTTPDigestAuth(username, password) else: auth_ = HTTPBasicAuth(username, password) - # load file from url with authentication + # Load file from URL with authentication req = requests.get(url, auth=auth_, timeout=CONF_TIMEOUT) else: - # load file from url without authentication + # Load file from URL without authentication req = requests.get(url, timeout=CONF_TIMEOUT) return req.content elif local_path is not None: # Check whether path is whitelisted in configuration.yaml if self.is_allowed_path(local_path): - # load file from local path on server return open(local_path, "rb") _LOGGER.warning("'%s' is not secure to load data from!", local_path) else: - # neither url nor path provided - _LOGGER.warning("Neither url nor local path found in params!") + _LOGGER.warning("Neither URL nor local path found in params!") except OSError as error: _LOGGER.error("Can't load from url or local path: %s", error) diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py index 9d2a8c079322b..25e6fc00a2f7e 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -8,6 +8,8 @@ import logging import mimetypes import os +from datetime import timedelta, datetime +from functools import partial import voluptuous as vol @@ -15,6 +17,7 @@ from homeassistant.components.notify import ( ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME +from homeassistant.helpers.event import async_track_point_in_time REQUIREMENTS = ['TwitterAPI==2.4.6'] @@ -68,49 +71,67 @@ def send_message(self, message="", **kwargs): _LOGGER.warning("'%s' is not a whitelisted directory", media) return - media_id = self.upload_media(media) + callback = partial(self.send_message_callback, message) + self.upload_media_then_callback(callback, media) + + def send_message_callback(self, message, media_id): + """Tweet a message, optionally with media.""" if self.user: resp = self.api.request('direct_messages/new', - {'text': message, 'user': self.user, + {'user': self.user, + 'text': message, 'media_ids': media_id}) else: resp = self.api.request('statuses/update', - {'status': message, 'media_ids': media_id}) + {'status': message, + 'media_ids': media_id}) if resp.status_code != 200: self.log_error_resp(resp) + else: + _LOGGER.debug("Message posted: %s", resp.json()) - def upload_media(self, media_path=None): + def upload_media_then_callback(self, callback, media_path=None): """Upload media.""" if not media_path: return None - (media_type, _) = mimetypes.guess_type(media_path) - total_bytes = os.path.getsize(media_path) + with open(media_path, 'rb') as file: + total_bytes = os.path.getsize(media_path) + (media_category, media_type) = self.media_info(media_path) + resp = self.upload_media_init( + media_type, media_category, total_bytes + ) - file = open(media_path, 'rb') - resp = self.upload_media_init(media_type, total_bytes) + if 199 > resp.status_code < 300: + self.log_error_resp(resp) + return None - if 199 > resp.status_code < 300: - self.log_error_resp(resp) - return None + media_id = resp.json()['media_id'] + media_id = self.upload_media_chunked(file, total_bytes, media_id) - media_id = resp.json()['media_id'] - media_id = self.upload_media_chunked(file, total_bytes, media_id) + resp = self.upload_media_finalize(media_id) + if 199 > resp.status_code < 300: + self.log_error_resp(resp) + return None - resp = self.upload_media_finalize(media_id) - if 199 > resp.status_code < 300: - self.log_error_resp(resp) + self.check_status_until_done(media_id, callback) - return media_id + def media_info(self, media_path): + """Determine mime type and Twitter media category for given media.""" + (media_type, _) = mimetypes.guess_type(media_path) + media_category = self.media_category_for_type(media_type) + _LOGGER.debug("media %s is mime type %s and translates to %s", + media_path, media_type, media_category) + return media_category, media_type - def upload_media_init(self, media_type, total_bytes): + def upload_media_init(self, media_type, media_category, total_bytes): """Upload media, INIT phase.""" - resp = self.api.request('media/upload', + return self.api.request('media/upload', {'command': 'INIT', 'media_type': media_type, + 'media_category': media_category, 'total_bytes': total_bytes}) - return resp def upload_media_chunked(self, file, total_bytes, media_id): """Upload media, chunked append.""" @@ -128,17 +149,55 @@ def upload_media_chunked(self, file, total_bytes, media_id): return media_id def upload_media_append(self, chunk, media_id, segment_id): - """Upload media, append phase.""" + """Upload media, APPEND phase.""" return self.api.request('media/upload', {'command': 'APPEND', 'media_id': media_id, 'segment_index': segment_id}, {'media': chunk}) def upload_media_finalize(self, media_id): - """Upload media, finalize phase.""" + """Upload media, FINALIZE phase.""" return self.api.request('media/upload', {'command': 'FINALIZE', 'media_id': media_id}) + def check_status_until_done(self, media_id, callback, *args): + """Upload media, STATUS phase.""" + resp = self.api.request('media/upload', + {'command': 'STATUS', 'media_id': media_id}, + method_override='GET') + if resp.status_code != 200: + _LOGGER.error("media processing error: %s", resp.json()) + processing_info = resp.json()['processing_info'] + + _LOGGER.debug("media processing %s status: %s", media_id, + processing_info) + + if processing_info['state'] in {u'succeeded', u'failed'}: + return callback(media_id) + + check_after_secs = processing_info['check_after_secs'] + _LOGGER.debug("media processing waiting %s seconds to check status", + str(check_after_secs)) + + when = datetime.now() + timedelta(seconds=check_after_secs) + myself = partial(self.check_status_until_done, media_id, callback) + async_track_point_in_time(self.hass, myself, when) + + @staticmethod + def media_category_for_type(media_type): + """Determine Twitter media category by mime type.""" + if media_type is None: + return None + + if media_type.startswith('image/gif'): + return 'tweet_gif' + elif media_type.startswith('video/'): + return 'tweet_video' + elif media_type.startswith('image/'): + return 'tweet_image' + + return None + @staticmethod def log_bytes_sent(bytes_sent, total_bytes): """Log upload progress.""" diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index d04eb91b6c4aa..f93e1b8f42657 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -15,18 +15,20 @@ REQUIREMENTS = ['sleekxmpp==1.3.2', 'dnspython3==1.15.0', - 'pyasn1==0.3.2', - 'pyasn1-modules==0.0.11'] + 'pyasn1==0.3.3', + 'pyasn1-modules==0.1.1'] _LOGGER = logging.getLogger(__name__) CONF_TLS = 'tls' +CONF_VERIFY = 'verify' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SENDER): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_RECIPIENT): cv.string, vol.Optional(CONF_TLS, default=True): cv.boolean, + vol.Optional(CONF_VERIFY, default=True): cv.boolean, }) @@ -34,18 +36,20 @@ def 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_RECIPIENT), config.get(CONF_TLS), + config.get(CONF_VERIFY)) class XmppNotificationService(BaseNotificationService): """Implement the notification service for Jabber (XMPP).""" - def __init__(self, sender, password, recipient, tls): + def __init__(self, sender, password, recipient, tls, verify): """Initialize the service.""" self._sender = sender self._password = password self._recipient = recipient self._tls = tls + self._verify = verify def send_message(self, message="", **kwargs): """Send a message to a user.""" @@ -53,10 +57,11 @@ def send_message(self, message="", **kwargs): data = '{}: {}'.format(title, message) if title else message send_message('{}/home-assistant'.format(self._sender), self._password, - self._recipient, self._tls, data) + self._recipient, self._tls, self._verify, data) -def send_message(sender, password, recipient, use_tls, message): +def send_message(sender, password, recipient, use_tls, + verify_certificate, message): """Send a message over XMPP.""" import sleekxmpp @@ -73,6 +78,10 @@ def __init__(self): self.use_ipv6 = False self.add_event_handler('failed_auth', self.check_credentials) self.add_event_handler('session_start', self.start) + 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() @@ -87,4 +96,10 @@ def check_credentials(self, event): """Disconnect from the server if credentials are invalid.""" self.disconnect() + @staticmethod + def discard_ssl_invalid_cert(event): + """Do nothing if ssl certificate is invalid.""" + _LOGGER.info('Ignoring invalid ssl certificate as requested.') + return + SendNotificationBot() diff --git a/homeassistant/components/octoprint.py b/homeassistant/components/octoprint.py index 204490ce36cf5..fdf237d7180ff 100644 --- a/homeassistant/components/octoprint.py +++ b/homeassistant/components/octoprint.py @@ -16,11 +16,15 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'octoprint' +CONF_NUMBER_OF_TOOLS = 'number_of_tools' +CONF_BED = 'bed' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NUMBER_OF_TOOLS, default=0): cv.positive_int, + vol.Optional(CONF_BED, default=False): cv.boolean }), }, extra=vol.ALLOW_EXTRA) @@ -29,11 +33,13 @@ 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] hass.data[DOMAIN] = {"api": None} try: - octoprint_api = OctoPrintAPI(base_url, api_key) + 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') @@ -46,7 +52,7 @@ def setup(hass, config): class OctoPrintAPI(object): """Simple JSON wrapper for OctoPrint's API.""" - def __init__(self, api_url, key): + def __init__(self, api_url, key, bed, number_of_tools): """Initialize OctoPrint API and set headers needed later.""" self.api_url = api_url self.headers = {'content-type': CONTENT_TYPE_JSON, @@ -58,11 +64,23 @@ def __init__(self, api_url, key): self.available = False self.printer_error_logged = False self.job_error_logged = False + self.bed = bed + self.number_of_tools = number_of_tools + _LOGGER.error(str(bed) + " " + str(number_of_tools)) def get_tools(self): - """Get the dynamic list of tools that temperature is monitored on.""" - tools = self.printer_last_reading[0]['temperature'] - return tools.keys() + """Get the list of tools that temperature is monitored on.""" + tools = [] + if self.number_of_tools > 0: + for tool_number in range(0, self.number_of_tools): + tools.append("tool" + str(tool_number)) + if self.bed: + tools.append('bed') + if not self.bed and self.number_of_tools == 0: + temps = self.printer_last_reading[0].get('temperature') + if temps is not None: + tools = temps.keys() + return tools def get(self, endpoint): """Send a get request, and return the response as a dict.""" diff --git a/homeassistant/components/prometheus.py b/homeassistant/components/prometheus.py index f244bcdd74010..0396cafd4ffaa 100644 --- a/homeassistant/components/prometheus.py +++ b/homeassistant/components/prometheus.py @@ -12,16 +12,18 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components import recorder -from homeassistant.const import (CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, - CONF_INCLUDE, EVENT_STATE_CHANGED, - TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant.const import ( + CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE, TEMP_CELSIUS, + EVENT_STATE_CHANGED, TEMP_FAHRENHEIT, CONTENT_TYPE_TEXT_PLAIN) from homeassistant import core as hacore from homeassistant.helpers import state as state_helper from homeassistant.util.temperature import fahrenheit_to_celsius +REQUIREMENTS = ['prometheus_client==0.0.19'] + _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['prometheus_client==0.0.19'] +API_ENDPOINT = '/api/prometheus' DOMAIN = 'prometheus' DEPENDENCIES = ['http'] @@ -30,8 +32,6 @@ DOMAIN: recorder.FILTER_SCHEMA, }, extra=vol.ALLOW_EXTRA) -API_ENDPOINT = '/api/prometheus' - def setup(hass, config): """Activate Prometheus component.""" @@ -45,11 +45,10 @@ def setup(hass, config): metrics = Metrics(prometheus_client, exclude, include) hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_event) - return True -class Metrics: +class Metrics(object): """Model all of the metrics which should be exposed to Prometheus.""" def __init__(self, prometheus_client, exclude, include): @@ -81,7 +80,7 @@ def handle_event(self, event): entity_id not in self.include_entities): return - handler = '_handle_' + domain + handler = '_handle_{}'.format(domain) if hasattr(self, handler): getattr(self, handler)(state) @@ -233,8 +232,8 @@ def __init__(self, prometheus_client): @asyncio.coroutine def get(self, request): """Handle request for Prometheus metrics.""" - _LOGGER.debug('Received Prometheus metrics request') + _LOGGER.debug("Received Prometheus metrics request") return web.Response( body=self.prometheus_client.generate_latest(), - content_type="text/plain") + content_type=CONTENT_TYPE_TEXT_PLAIN) diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index e3ffc2f24a87d..0c5acd3f7fa24 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -4,6 +4,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/rfxtrx/ """ + import logging from collections import OrderedDict import voluptuous as vol @@ -11,13 +12,14 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util import slugify from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ATTR_ENTITY_ID, TEMP_CELSIUS, CONF_DEVICE_CLASS, CONF_COMMAND_ON, CONF_COMMAND_OFF ) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyRFXtrx==0.19.0'] +REQUIREMENTS = ['pyRFXtrx==0.20.1'] DOMAIN = 'rfxtrx' @@ -54,7 +56,7 @@ RECEIVED_EVT_SUBSCRIBERS = [] RFX_DEVICES = {} _LOGGER = logging.getLogger(__name__) -RFXOBJECT = None +RFXOBJECT = 'rfxobject' def _valid_device(value, device_type): @@ -77,10 +79,6 @@ def _valid_device(value, device_type): if not len(key) % 2 == 0: key = '0' + key - if get_rfx_object(key) is None: - raise vol.Invalid('Rfxtrx device {} is invalid: ' - 'Invalid device id for {}'.format(key, value)) - if device_type == 'sensor': config[key] = DEVICE_SCHEMA_SENSOR(device) elif device_type == 'binary_sensor': @@ -171,24 +169,24 @@ def handle_receive(event): # Try to load the RFXtrx module. import RFXtrx as rfxtrxmod - # Init the rfxtrx module. - global RFXOBJECT - device = config[DOMAIN][ATTR_DEVICE] debug = config[DOMAIN][ATTR_DEBUG] dummy_connection = config[DOMAIN][ATTR_DUMMY] if dummy_connection: - RFXOBJECT =\ - rfxtrxmod.Connect(device, handle_receive, debug=debug, + hass.data[RFXOBJECT] =\ + rfxtrxmod.Connect(device, None, debug=debug, transport_protocol=rfxtrxmod.DummyTransport2) else: - RFXOBJECT = rfxtrxmod.Connect(device, handle_receive, debug=debug) + hass.data[RFXOBJECT] = rfxtrxmod.Connect(device, None, debug=debug) + + def _start_rfxtrx(event): + hass.data[RFXOBJECT].event_callback = handle_receive + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_rfxtrx) def _shutdown_rfxtrx(event): """Close connection with RFXtrx.""" - RFXOBJECT.close_connection() - + hass.data[RFXOBJECT].close_connection() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx) return True @@ -285,13 +283,16 @@ def find_possible_pt2262_device(device_id): return None -def get_devices_from_config(config, device, hass): +def get_devices_from_config(config, device): """Read rfxtrx configuration.""" signal_repetitions = config[CONF_SIGNAL_REPETITIONS] devices = [] for packet_id, entity_info in config[CONF_DEVICES].items(): event = get_rfx_object(packet_id) + if event is None: + _LOGGER.error("Invalid device: %s", packet_id) + continue device_id = slugify(event.device.id_string.lower()) if device_id in RFX_DEVICES: continue @@ -303,13 +304,12 @@ def get_devices_from_config(config, device, hass): new_device = device(entity_info[ATTR_NAME], event, datas, signal_repetitions) - new_device.hass = hass RFX_DEVICES[device_id] = new_device devices.append(new_device) return devices -def get_new_device(event, config, device, hass): +def get_new_device(event, config, device): """Add entity if not exist and the automatic_add is True.""" device_id = slugify(event.device.id_string.lower()) if device_id in RFX_DEVICES: @@ -330,7 +330,6 @@ def get_new_device(event, config, device, hass): signal_repetitions = config[CONF_SIGNAL_REPETITIONS] new_device = device(pkt_id, event, datas, signal_repetitions) - new_device.hass = hass RFX_DEVICES[device_id] = new_device return new_device @@ -438,31 +437,36 @@ def _send_command(self, command, brightness=0): if command == "turn_on": for _ in range(self.signal_repetitions): - self._event.device.send_on(RFXOBJECT.transport) + self._event.device.send_on(self.hass.data[RFXOBJECT] + .transport) self._state = True elif command == "dim": for _ in range(self.signal_repetitions): - self._event.device.send_dim(RFXOBJECT.transport, - brightness) + self._event.device.send_dim(self.hass.data[RFXOBJECT] + .transport, brightness) self._state = True elif command == 'turn_off': for _ in range(self.signal_repetitions): - self._event.device.send_off(RFXOBJECT.transport) + self._event.device.send_off(self.hass.data[RFXOBJECT] + .transport) self._state = False self._brightness = 0 elif command == "roll_up": for _ in range(self.signal_repetitions): - self._event.device.send_open(RFXOBJECT.transport) + self._event.device.send_open(self.hass.data[RFXOBJECT] + .transport) elif command == "roll_down": for _ in range(self.signal_repetitions): - self._event.device.send_close(RFXOBJECT.transport) + self._event.device.send_close(self.hass.data[RFXOBJECT] + .transport) elif command == "stop_roll": for _ in range(self.signal_repetitions): - self._event.device.send_stop(RFXOBJECT.transport) + self._event.device.send_stop(self.hass.data[RFXOBJECT] + .transport) self.schedule_update_ha_state() diff --git a/homeassistant/components/script.py b/homeassistant/components/script.py index 62edb11b7784e..7be8bd8175e4c 100644 --- a/homeassistant/components/script.py +++ b/homeassistant/components/script.py @@ -39,13 +39,13 @@ GROUP_NAME_ALL_SCRIPTS = 'all scripts' -_SCRIPT_ENTRY_SCHEMA = vol.Schema({ +SCRIPT_ENTRY_SCHEMA = vol.Schema({ CONF_ALIAS: cv.string, vol.Required(CONF_SEQUENCE): cv.SCRIPT_SCHEMA, }) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({cv.slug: _SCRIPT_ENTRY_SCHEMA}) + DOMAIN: vol.Schema({cv.slug: SCRIPT_ENTRY_SCHEMA}) }, extra=vol.ALLOW_EXTRA) SCRIPT_SERVICE_SCHEMA = vol.Schema(dict) @@ -62,12 +62,6 @@ def is_on(hass, entity_id): return hass.states.is_state(entity_id, STATE_ON) -@bind_hass -def reload(hass): - """Reload script component.""" - hass.services.call(DOMAIN, SERVICE_RELOAD) - - @bind_hass def turn_on(hass, entity_id, variables=None): """Turn script on.""" @@ -88,6 +82,21 @@ def toggle(hass, entity_id): hass.services.call(DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: entity_id}) +@bind_hass +def reload(hass): + """Reload script component.""" + hass.services.call(DOMAIN, SERVICE_RELOAD) + + +@bind_hass +def async_reload(hass): + """Reload the scripts from config. + + Returns a coroutine object. + """ + return hass.services.async_call(DOMAIN, SERVICE_RELOAD) + + @asyncio.coroutine def async_setup(hass, config): """Load the scripts from the configuration.""" diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py new file mode 100644 index 0000000000000..7b077aa38eea0 --- /dev/null +++ b/homeassistant/components/sensor/airvisual.py @@ -0,0 +1,289 @@ +""" +Support for AirVisual air quality sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.airvisual/ +""" + +import asyncio +from logging import getLogger +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (ATTR_ATTRIBUTION, ATTR_STATE, CONF_API_KEY, + CONF_LATITUDE, CONF_LONGITUDE, + CONF_MONITORED_CONDITIONS) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = getLogger(__name__) +REQUIREMENTS = ['pyairvisual==0.1.0'] + +ATTR_CITY = 'city' +ATTR_COUNTRY = 'country' +ATTR_POLLUTANT_SYMBOL = 'pollutant_symbol' +ATTR_POLLUTANT_UNIT = 'pollutant_unit' +ATTR_TIMESTAMP = 'timestamp' + +CONF_RADIUS = 'radius' + +MASS_PARTS_PER_MILLION = 'ppm' +MASS_PARTS_PER_BILLION = 'ppb' +VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) + +POLLUTANT_LEVEL_MAPPING = [{ + 'label': 'Good', + 'minimum': 0, + 'maximum': 50 +}, { + 'label': 'Moderate', + 'minimum': 51, + 'maximum': 100 +}, { + 'label': 'Unhealthy for Sensitive Groups', + 'minimum': 101, + 'maximum': 150 +}, { + 'label': 'Unhealthy', + 'minimum': 151, + 'maximum': 200 +}, { + 'label': 'Very Unhealthy', + 'minimum': 201, + 'maximum': 300 +}, { + 'label': 'Hazardous', + 'minimum': 301, + 'maximum': 10000 +}] +POLLUTANT_MAPPING = { + 'co': { + 'label': 'Carbon Monoxide', + 'unit': MASS_PARTS_PER_MILLION + }, + 'n2': { + 'label': 'Nitrogen Dioxide', + 'unit': MASS_PARTS_PER_BILLION + }, + 'o3': { + 'label': 'Ozone', + 'unit': MASS_PARTS_PER_BILLION + }, + 'p1': { + 'label': 'PM10', + 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER + }, + 'p2': { + 'label': 'PM2.5', + 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER + }, + 's2': { + 'label': 'Sulfur Dioxide', + 'unit': MASS_PARTS_PER_BILLION + } +} + +SENSOR_LOCALES = {'cn': 'Chinese', 'us': 'U.S.'} +SENSOR_TYPES = [ + ('AirPollutionLevelSensor', 'Air Pollution Level', 'mdi:scale'), + ('AirQualityIndexSensor', 'Air Quality Index', 'mdi:format-list-numbers'), + ('MainPollutantSensor', 'Main Pollutant', 'mdi:chemical-weapon'), +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): + cv.string, + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(SENSOR_LOCALES)]), + vol.Optional(CONF_LATITUDE): + cv.latitude, + vol.Optional(CONF_LONGITUDE): + cv.longitude, + vol.Optional(CONF_RADIUS, default=1000): + cv.positive_int, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Configure the platform and add the sensors.""" + import pyairvisual as pav + + api_key = config.get(CONF_API_KEY) + _LOGGER.debug('AirVisual API Key: %s', api_key) + + monitored_locales = config.get(CONF_MONITORED_CONDITIONS) + _LOGGER.debug('Monitored Conditions: %s', monitored_locales) + + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + _LOGGER.debug('AirVisual Latitude: %s', latitude) + + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + _LOGGER.debug('AirVisual Longitude: %s', longitude) + + radius = config.get(CONF_RADIUS) + _LOGGER.debug('AirVisual Radius: %s', radius) + + data = AirVisualData(pav.Client(api_key), latitude, longitude, radius) + + sensors = [] + for locale in monitored_locales: + for sensor_class, name, icon in SENSOR_TYPES: + sensors.append(globals()[sensor_class](data, name, icon, locale)) + + async_add_devices(sensors, True) + + +def merge_two_dicts(dict1, dict2): + """Merge two dicts into a new dict as a shallow copy.""" + final = dict1.copy() + final.update(dict2) + return final + + +class AirVisualBaseSensor(Entity): + """Define a base class for all of our sensors.""" + + def __init__(self, data, name, icon, locale): + """Initialize.""" + self._data = data + self._icon = icon + self._locale = locale + self._name = name + self._state = None + self._unit = None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._data: + return { + ATTR_ATTRIBUTION: 'AirVisual©', + ATTR_CITY: self._data.city, + ATTR_COUNTRY: self._data.country, + ATTR_STATE: self._data.state, + ATTR_TIMESTAMP: self._data.pollution_info.get('ts') + } + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def name(self): + """Return the name.""" + return '{0} {1}'.format(SENSOR_LOCALES[self._locale], self._name) + + @property + def state(self): + """Return the state.""" + return self._state + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + _LOGGER.debug('updating sensor: %s', self._name) + self._data.update() + + +class AirPollutionLevelSensor(AirVisualBaseSensor): + """Define a sensor to measure air pollution level.""" + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + aqi = self._data.pollution_info.get('aqi{0}'.format(self._locale)) + + try: + [level] = [ + i for i in POLLUTANT_LEVEL_MAPPING + if i['minimum'] <= aqi <= i['maximum'] + ] + self._state = level.get('label') + except ValueError: + self._state = None + + +class AirQualityIndexSensor(AirVisualBaseSensor): + """Define a sensor to measure AQI.""" + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return '' + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + self._state = self._data.pollution_info.get( + 'aqi{0}'.format(self._locale)) + + +class MainPollutantSensor(AirVisualBaseSensor): + """Define a sensor to the main pollutant of an area.""" + + def __init__(self, data, name, icon, locale): + """Initialize.""" + super().__init__(data, name, icon, locale) + self._symbol = None + self._unit = None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._data: + return merge_two_dicts(super().device_state_attributes, { + ATTR_POLLUTANT_SYMBOL: self._symbol, + ATTR_POLLUTANT_UNIT: self._unit + }) + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + symbol = self._data.pollution_info.get('main{0}'.format(self._locale)) + pollution_info = POLLUTANT_MAPPING.get(symbol, {}) + self._state = pollution_info.get('label') + self._unit = pollution_info.get('unit') + self._symbol = symbol + + +class AirVisualData(object): + """Define an object to hold sensor data.""" + + def __init__(self, client, latitude, longitude, radius): + """Initialize.""" + self.city = None + self._client = client + self.country = None + self.latitude = latitude + self.longitude = longitude + self.pollution_info = None + self.radius = radius + self.state = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update with new AirVisual data.""" + import pyairvisual.exceptions as exceptions + + try: + resp = self._client.nearest_city(self.latitude, self.longitude, + self.radius).get('data') + _LOGGER.debug('New data retrieved: %s', resp) + + self.city = resp.get('city') + self.state = resp.get('state') + self.country = resp.get('country') + self.pollution_info = resp.get('current').get('pollution') + except exceptions.HTTPError as exc_info: + _LOGGER.error('Unable to update sensor data') + _LOGGER.debug(exc_info) diff --git a/homeassistant/components/sensor/buienradar.py b/homeassistant/components/sensor/buienradar.py index 755d88bb4432b..1b5cfc4b4914b 100755 --- a/homeassistant/components/sensor/buienradar.py +++ b/homeassistant/components/sensor/buienradar.py @@ -23,12 +23,14 @@ async_track_point_in_utc_time) from homeassistant.util import dt as dt_util -REQUIREMENTS = ['buienradar==0.8'] +REQUIREMENTS = ['buienradar==0.9'] _LOGGER = logging.getLogger(__name__) MEASURED_LABEL = 'Measured' TIMEFRAME_LABEL = 'Timeframe' +SYMBOL = 'symbol' + # Schedule next call after (minutes): SCHEDULE_OK = 10 # When an error occurred, new call after (minutes): @@ -38,6 +40,10 @@ # Key: ['label', unit, icon] SENSOR_TYPES = { 'stationname': ['Stationname', None, None], + 'condition': ['Condition', None, None], + 'conditioncode': ['Condition code', None, None], + 'conditiondetailed': ['Detailed condition', None, None], + 'conditionexact': ['Full condition', None, None], 'symbol': ['Symbol', None, None], 'humidity': ['Humidity', '%', 'mdi:water-percent'], 'temperature': ['Temperature', TEMP_CELSIUS, 'mdi:thermometer'], @@ -55,7 +61,67 @@ 'precipitation_forecast_average': ['Precipitation forecast average', 'mm/h', 'mdi:weather-pouring'], 'precipitation_forecast_total': ['Precipitation forecast total', - 'mm', 'mdi:weather-pouring'] + 'mm', 'mdi:weather-pouring'], + 'temperature_1d': ['Temperature 1d', TEMP_CELSIUS, 'mdi:thermometer'], + 'temperature_2d': ['Temperature 2d', TEMP_CELSIUS, 'mdi:thermometer'], + 'temperature_3d': ['Temperature 3d', TEMP_CELSIUS, 'mdi:thermometer'], + 'temperature_4d': ['Temperature 4d', TEMP_CELSIUS, 'mdi:thermometer'], + 'temperature_5d': ['Temperature 5d', TEMP_CELSIUS, 'mdi:thermometer'], + 'mintemp_1d': ['Minimum temperature 1d', TEMP_CELSIUS, 'mdi:thermometer'], + 'mintemp_2d': ['Minimum temperature 2d', TEMP_CELSIUS, 'mdi:thermometer'], + 'mintemp_3d': ['Minimum temperature 3d', TEMP_CELSIUS, 'mdi:thermometer'], + 'mintemp_4d': ['Minimum temperature 4d', TEMP_CELSIUS, 'mdi:thermometer'], + 'mintemp_5d': ['Minimum temperature 5d', TEMP_CELSIUS, 'mdi:thermometer'], + 'rain_1d': ['Rain 1d', 'mm', 'mdi:weather-pouring'], + 'rain_2d': ['Rain 2d', 'mm', 'mdi:weather-pouring'], + 'rain_3d': ['Rain 3d', 'mm', 'mdi:weather-pouring'], + 'rain_4d': ['Rain 4d', 'mm', 'mdi:weather-pouring'], + 'rain_5d': ['Rain 5d', 'mm', 'mdi:weather-pouring'], + 'snow_1d': ['Snow 1d', 'cm', 'mdi:snowflake'], + 'snow_2d': ['Snow 2d', 'cm', 'mdi:snowflake'], + 'snow_3d': ['Snow 3d', 'cm', 'mdi:snowflake'], + 'snow_4d': ['Snow 4d', 'cm', 'mdi:snowflake'], + 'snow_5d': ['Snow 5d', 'cm', 'mdi:snowflake'], + 'rainchance_1d': ['Rainchance 1d', '%', 'mdi:weather-pouring'], + 'rainchance_2d': ['Rainchance 2d', '%', 'mdi:weather-pouring'], + 'rainchance_3d': ['Rainchance 3d', '%', 'mdi:weather-pouring'], + 'rainchance_4d': ['Rainchance 4d', '%', 'mdi:weather-pouring'], + 'rainchance_5d': ['Rainchance 5d', '%', 'mdi:weather-pouring'], + 'sunchance_1d': ['Sunchance 1d', '%', 'mdi:weather-partlycloudy'], + 'sunchance_2d': ['Sunchance 2d', '%', 'mdi:weather-partlycloudy'], + 'sunchance_3d': ['Sunchance 3d', '%', 'mdi:weather-partlycloudy'], + 'sunchance_4d': ['Sunchance 4d', '%', 'mdi:weather-partlycloudy'], + 'sunchance_5d': ['Sunchance 5d', '%', 'mdi:weather-partlycloudy'], + 'windforce_1d': ['Wind force 1d', 'Bft', 'mdi:weather-windy'], + 'windforce_2d': ['Wind force 2d', 'Bft', 'mdi:weather-windy'], + 'windforce_3d': ['Wind force 3d', 'Bft', 'mdi:weather-windy'], + 'windforce_4d': ['Wind force 4d', 'Bft', 'mdi:weather-windy'], + 'windforce_5d': ['Wind force 5d', 'Bft', 'mdi:weather-windy'], + 'condition_1d': ['Condition 1d', None, None], + 'condition_2d': ['Condition 2d', None, None], + 'condition_3d': ['Condition 3d', None, None], + 'condition_4d': ['Condition 4d', None, None], + 'condition_5d': ['Condition 5d', None, None], + 'conditioncode_1d': ['Condition code 1d', None, None], + 'conditioncode_2d': ['Condition code 2d', None, None], + 'conditioncode_3d': ['Condition code 3d', None, None], + 'conditioncode_4d': ['Condition code 4d', None, None], + 'conditioncode_5d': ['Condition code 5d', None, None], + 'conditiondetailed_1d': ['Detailed condition 1d', None, None], + 'conditiondetailed_2d': ['Detailed condition 2d', None, None], + 'conditiondetailed_3d': ['Detailed condition 3d', None, None], + 'conditiondetailed_4d': ['Detailed condition 4d', None, None], + 'conditiondetailed_5d': ['Detailed condition 5d', None, None], + 'conditionexact_1d': ['Full condition 1d', None, None], + 'conditionexact_2d': ['Full condition 2d', None, None], + 'conditionexact_3d': ['Full condition 3d', None, None], + 'conditionexact_4d': ['Full condition 4d', None, None], + 'conditionexact_5d': ['Full condition 5d', None, None], + 'symbol_1d': ['Symbol 1d', None, None], + 'symbol_2d': ['Symbol 2d', None, None], + 'symbol_3d': ['Symbol 3d', None, None], + 'symbol_4d': ['Symbol 4d', None, None], + 'symbol_5d': ['Symbol 5d', None, None], } CONF_TIMEFRAME = 'timeframe' @@ -126,23 +192,95 @@ def __init__(self, sensor_type, client_name): def load_data(self, data): """Load the sensor with relevant data.""" # Find sensor - from buienradar.buienradar import (ATTRIBUTION, IMAGE, MEASURED, + from buienradar.buienradar import (ATTRIBUTION, CONDITION, CONDCODE, + DETAILED, EXACT, EXACTNL, FORECAST, + IMAGE, MEASURED, PRECIPITATION_FORECAST, STATIONNAME, - SYMBOL, TIMEFRAME) + TIMEFRAME) self._attribution = data.get(ATTRIBUTION) self._stationname = data.get(STATIONNAME) self._measured = data.get(MEASURED) - if self.type == SYMBOL: + + if self.type.endswith('_1d') or \ + self.type.endswith('_2d') or \ + self.type.endswith('_3d') or \ + self.type.endswith('_4d') or \ + self.type.endswith('_5d'): + + fcday = 0 + if self.type.endswith('_2d'): + fcday = 1 + if self.type.endswith('_3d'): + fcday = 2 + if self.type.endswith('_4d'): + fcday = 3 + if self.type.endswith('_5d'): + fcday = 4 + + # update all other sensors + if self.type.startswith(SYMBOL) or self.type.startswith(CONDITION): + try: + condition = data.get(FORECAST)[fcday].get(CONDITION) + except IndexError: + _LOGGER.warning("No forecast for fcday=%s...", fcday) + return False + + if condition: + new_state = condition.get(CONDITION, None) + if self.type.startswith(SYMBOL): + new_state = condition.get(EXACTNL, None) + if self.type.startswith('conditioncode'): + new_state = condition.get(CONDCODE, None) + if self.type.startswith('conditiondetailed'): + new_state = condition.get(DETAILED, None) + if self.type.startswith('conditionexact'): + new_state = condition.get(EXACT, None) + + img = condition.get(IMAGE, None) + + if new_state != self._state or img != self._entity_picture: + self._state = new_state + self._entity_picture = img + return True + return False + else: + try: + new_state = data.get(FORECAST)[fcday].get(self.type[:-3]) + except IndexError: + _LOGGER.warning("No forecast for fcday=%s...", fcday) + return False + + if new_state != self._state: + self._state = new_state + return True + return False + + return False + + if self.type == SYMBOL or self.type.startswith(CONDITION): # update weather symbol & status text - new_state = data.get(self.type) - img = data.get(IMAGE) + condition = data.get(CONDITION, None) + if condition: + if self.type == SYMBOL: + new_state = condition.get(EXACTNL, None) + if self.type == CONDITION: + new_state = condition.get(CONDITION, None) + if self.type == 'conditioncode': + new_state = condition.get(CONDCODE, None) + if self.type == 'conditiondetailed': + new_state = condition.get(DETAILED, None) + if self.type == 'conditionexact': + new_state = condition.get(EXACT, None) + + img = condition.get(IMAGE, None) + + # pylint: disable=protected-access + if new_state != self._state or img != self._entity_picture: + self._state = new_state + self._entity_picture = img + return True - # pylint: disable=protected-access - if new_state != self._state or img != self._entity_picture: - self._state = new_state - self._entity_picture = img - return True return False if self.type.startswith(PRECIPITATION_FORECAST): @@ -187,11 +325,6 @@ def should_poll(self): # pylint: disable=no-self-use @property def entity_picture(self): """Weather symbol if type is symbol.""" - from buienradar.buienradar import SYMBOL - - if self.type != SYMBOL: - return None - return self._entity_picture @property @@ -360,8 +493,8 @@ def stationname(self): @property def condition(self): """Return the condition.""" - from buienradar.buienradar import SYMBOL - return self.data.get(SYMBOL) + from buienradar.buienradar import CONDITION + return self.data.get(CONDITION) @property def temperature(self): @@ -390,6 +523,15 @@ def humidity(self): except (ValueError, TypeError): return None + @property + def visibility(self): + """Return the visibility, or None.""" + from buienradar.buienradar import VISIBILITY + try: + return int(self.data.get(VISIBILITY)) + except (ValueError, TypeError): + return None + @property def wind_speed(self): """Return the windspeed, or None.""" @@ -402,9 +544,9 @@ def wind_speed(self): @property def wind_bearing(self): """Return the wind bearing, or None.""" - from buienradar.buienradar import WINDDIRECTION + from buienradar.buienradar import WINDAZIMUTH try: - return int(self.data.get(WINDDIRECTION)) + return int(self.data.get(WINDAZIMUTH)) except (ValueError, TypeError): return None diff --git a/homeassistant/components/sensor/cert_expiry.py b/homeassistant/components/sensor/cert_expiry.py index dfc15510d6fd2..1ccaf2f692532 100644 --- a/homeassistant/components/sensor/cert_expiry.py +++ b/homeassistant/components/sensor/cert_expiry.py @@ -4,16 +4,17 @@ For more details about this sensor please refer to the documentation at https://home-assistant.io/components/sensor.cert_expiry/ """ -import datetime import logging import socket import ssl +from datetime import datetime, timedelta import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_PORT) +from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_PORT, + EVENT_HOMEASSISTANT_START) from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -21,7 +22,7 @@ DEFAULT_NAME = 'SSL Certificate Expiry' DEFAULT_PORT = 443 -SCAN_INTERVAL = datetime.timedelta(hours=12) +SCAN_INTERVAL = timedelta(hours=12) TIMEOUT = 10.0 @@ -34,11 +35,20 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up certificate expiry sensor.""" - server_name = config.get(CONF_HOST) - server_port = config.get(CONF_PORT) - sensor_name = config.get(CONF_NAME) + def run_setup(event): + """Wait until Home Assistant is fully initialized before creating. - add_devices([SSLCertificate(sensor_name, server_name, server_port)], True) + Delay the setup until Home Assistant is fully initialized. + """ + server_name = config.get(CONF_HOST) + server_port = config.get(CONF_PORT) + sensor_name = config.get(CONF_NAME) + + add_devices([SSLCertificate(sensor_name, server_name, server_port)], + True) + + # To allow checking of the HA certificate we must first be running. + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, run_setup) class SSLCertificate(Entity): @@ -97,6 +107,6 @@ def update(self): return ts_seconds = ssl.cert_time_to_seconds(cert['notAfter']) - timestamp = datetime.datetime.fromtimestamp(ts_seconds) - expiry = timestamp - datetime.datetime.today() + timestamp = datetime.fromtimestamp(ts_seconds) + expiry = timestamp - datetime.today() self._state = expiry.days diff --git a/homeassistant/components/sensor/dht.py b/homeassistant/components/sensor/dht.py index 8fa34d50137f8..cbf06783dc752 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -127,7 +127,7 @@ def update(self): humidity_offset = self.humidity_offset data = self.dht_client.data - if self.type == SENSOR_TEMPERATURE: + if self.type == SENSOR_TEMPERATURE and SENSOR_TEMPERATURE in data: temperature = data[SENSOR_TEMPERATURE] _LOGGER.debug("Temperature %.1f \u00b0C + offset %.1f", temperature, temperature_offset) @@ -135,7 +135,7 @@ def update(self): self._state = round(temperature + temperature_offset, 1) if self.temp_unit == TEMP_FAHRENHEIT: self._state = round(celsius_to_fahrenheit(temperature), 1) - elif self.type == SENSOR_HUMIDITY: + elif self.type == SENSOR_HUMIDITY and SENSOR_HUMIDITY in data: humidity = data[SENSOR_HUMIDITY] _LOGGER.debug("Humidity %.1f%% + offset %.1f", humidity, humidity_offset) diff --git a/homeassistant/components/sensor/dwd_weather_warnings.py b/homeassistant/components/sensor/dwd_weather_warnings.py new file mode 100644 index 0000000000000..0eeaa9424e822 --- /dev/null +++ b/homeassistant/components/sensor/dwd_weather_warnings.py @@ -0,0 +1,243 @@ +""" +Support for getting statistical data from a DWD Weather Warnings. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.dwd_weather_warnings/ + +Data is fetched from DWD: +https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html + +Warnungen vor extremem Unwetter (Stufe 4) +Unwetterwarnungen (Stufe 3) +Warnungen vor markantem Wetter (Stufe 2) +Wetterwarnungen (Stufe 1) +""" +import logging +import json +from datetime import timedelta + +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 ( + ATTR_ATTRIBUTION, CONF_NAME, CONF_MONITORED_CONDITIONS) +from homeassistant.util import Throttle +import homeassistant.util.dt as dt_util +from homeassistant.components.sensor.rest import RestData + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Data provided by DWD" + +DEFAULT_NAME = 'DWD-Weather-Warnings' + +CONF_REGION_NAME = 'region_name' + +SCAN_INTERVAL = timedelta(minutes=15) + +MONITORED_CONDITIONS = { + 'current_warning_level': ['Current Warning Level', + None, 'mdi:close-octagon-outline'], + 'advance_warning_level': ['Advance Warning Level', + None, 'mdi:close-octagon-outline'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_REGION_NAME): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the DWD-Weather-Warnings sensor.""" + name = config.get(CONF_NAME) + region_name = config.get(CONF_REGION_NAME) + + api = DwdWeatherWarningsAPI(region_name) + + sensors = [DwdWeatherWarningsSensor(api, name, condition) + for condition in config[CONF_MONITORED_CONDITIONS]] + + add_devices(sensors, True) + + +class DwdWeatherWarningsSensor(Entity): + """Representation of a DWD-Weather-Warnings sensor.""" + + def __init__(self, api, name, variable): + """Initialize a DWD-Weather-Warnings sensor.""" + self._api = api + self._name = name + self._var_id = variable + + variable_info = MONITORED_CONDITIONS[variable] + self._var_name = variable_info[0] + self._var_units = variable_info[1] + self._var_icon = variable_info[2] + + @property + def name(self): + """Return the name of the sensor.""" + return "{} {}".format(self._name, self._var_name) + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._var_icon + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._var_units + + # pylint: disable=no-member + @property + def state(self): + """Return the state of the device.""" + try: + return round(self._api.data[self._var_id], 2) + except TypeError: + return self._api.data[self._var_id] + + # pylint: disable=no-member + @property + def device_state_attributes(self): + """Return the state attributes of the DWD-Weather-Warnings.""" + data = { + ATTR_ATTRIBUTION: ATTRIBUTION, + 'region_name': self._api.region_name + } + + if self._api.region_id is not None: + data['region_id'] = self._api.region_id + + if self._api.region_state is not None: + data['region_state'] = self._api.region_state + + if self._api.data['time'] is not None: + data['last_update'] = dt_util.as_local( + dt_util.utc_from_timestamp(self._api.data['time'] / 1000)) + + if self._var_id == 'current_warning_level': + prefix = 'current' + elif self._var_id == 'advance_warning_level': + prefix = 'advance' + else: + raise Exception('Unknown warning type') + + data['warning_count'] = self._api.data[prefix + '_warning_count'] + i = 0 + for event in self._api.data[prefix + '_warnings']: + i = i + 1 + + data['warning_{}_name'.format(i)] = event['event'] + data['warning_{}_level'.format(i)] = event['level'] + data['warning_{}_type'.format(i)] = event['type'] + if len(event['headline']) > 0: + data['warning_{}_headline'.format(i)] = event['headline'] + if len(event['description']) > 0: + data['warning_{}_description'.format(i)] = event['description'] + if len(event['instruction']) > 0: + data['warning_{}_instruction'.format(i)] = event['instruction'] + + if event['start'] is not None: + data['warning_{}_start'.format(i)] = dt_util.as_local( + dt_util.utc_from_timestamp(event['start'] / 1000)) + + if event['end'] is not None: + data['warning_{}_end'.format(i)] = dt_util.as_local( + dt_util.utc_from_timestamp(event['end'] / 1000)) + + return data + + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self._api.available + + def update(self): + """Get the latest data from the DWD-Weather-Warnings API.""" + self._api.update() + + +class DwdWeatherWarningsAPI(object): + """Get the latest data and update the states.""" + + def __init__(self, region_name): + """Initialize the data object.""" + resource = "{}{}{}?{}".format( + 'https://', + 'www.dwd.de', + '/DWD/warnungen/warnapp_landkreise/json/warnings.json', + 'jsonp=loadWarnings' + ) + + self._rest = RestData('GET', resource, None, None, None, True) + self.region_name = region_name + self.region_id = None + self.region_state = None + self.data = None + self.available = True + self.update() + + @Throttle(SCAN_INTERVAL) + def update(self): + """Get the latest data from the DWD-Weather-Warnings.""" + try: + self._rest.update() + + json_string = self._rest.data[24:len(self._rest.data) - 2] + json_obj = json.loads(json_string) + + data = {'time': json_obj['time']} + + for mykey, myvalue in { + 'current': 'warnings', + 'advance': 'vorabInformation' + }.items(): + + _LOGGER.debug("Found %d %s global DWD warnings", + len(json_obj[myvalue]), mykey) + + data['{}_warning_level'.format(mykey)] = 0 + my_warnings = [] + + if self.region_id is not None: + # get a specific region_id + if self.region_id in json_obj[myvalue]: + my_warnings = json_obj[myvalue][self.region_id] + + else: + # loop through all items to find warnings, region_id + # and region_state for region_name + for key in json_obj[myvalue]: + my_region = json_obj[myvalue][key][0]['regionName'] + if my_region != self.region_name: + continue + my_warnings = json_obj[myvalue][key] + my_state = json_obj[myvalue][key][0]['stateShort'] + self.region_id = key + self.region_state = my_state + break + + # Get max warning level + maxlevel = data['{}_warning_level'.format(mykey)] + for event in my_warnings: + if event['level'] >= maxlevel: + data['{}_warning_level'.format(mykey)] = event['level'] + + data['{}_warning_count'.format(mykey)] = len(my_warnings) + data['{}_warnings'.format(mykey)] = my_warnings + + _LOGGER.debug("Found %d %s local DWD warnings", + len(my_warnings), mykey) + + self.data = data + self.available = True + except TypeError: + _LOGGER.error("Unable to fetch data from DWD-Weather-Warnings") + self.available = False diff --git a/homeassistant/components/sensor/eliqonline.py b/homeassistant/components/sensor/eliqonline.py index 5da52272fb12f..dc879fe0d3e26 100644 --- a/homeassistant/components/sensor/eliqonline.py +++ b/homeassistant/components/sensor/eliqonline.py @@ -30,7 +30,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ACCESS_TOKEN): cv.string, - vol.Optional(CONF_CHANNEL_ID): cv.string, + vol.Optional(CONF_CHANNEL_ID): cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) diff --git a/homeassistant/components/sensor/fedex.py b/homeassistant/components/sensor/fedex.py index 5874e8ce48708..6b159760b3c88 100644 --- a/homeassistant/components/sensor/fedex.py +++ b/homeassistant/components/sensor/fedex.py @@ -19,7 +19,7 @@ from homeassistant.util.dt import now, parse_date import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['fedexdeliverymanager==1.0.3'] +REQUIREMENTS = ['fedexdeliverymanager==1.0.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index c0256e3a88b86..5876a05967286 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -17,10 +17,10 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.helpers.entity import Entity -from homeassistant.loader import get_component +from homeassistant.util.icon import icon_for_battery_level import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['fitbit==0.2.3'] +REQUIREMENTS = ['fitbit==0.3.0'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) @@ -32,6 +32,7 @@ ATTR_LAST_SAVED_AT = 'last_saved_at' CONF_MONITORED_RESOURCES = 'monitored_resources' +CONF_CLOCK_FORMAT = 'clock_format' CONF_ATTRIBUTION = 'Data provided by Fitbit.com' DEPENDENCIES = ['http'] @@ -49,40 +50,50 @@ } FITBIT_RESOURCES_LIST = { - 'activities/activityCalories': 'cal', - 'activities/calories': 'cal', - 'activities/caloriesBMR': 'cal', - 'activities/distance': '', - 'activities/elevation': '', - 'activities/floors': 'floors', - 'activities/heart': 'bpm', - 'activities/minutesFairlyActive': 'minutes', - 'activities/minutesLightlyActive': 'minutes', - 'activities/minutesSedentary': 'minutes', - 'activities/minutesVeryActive': 'minutes', - 'activities/steps': 'steps', - 'activities/tracker/activityCalories': 'cal', - 'activities/tracker/calories': 'cal', - 'activities/tracker/distance': '', - 'activities/tracker/elevation': '', - 'activities/tracker/floors': 'floors', - 'activities/tracker/minutesFairlyActive': 'minutes', - 'activities/tracker/minutesLightlyActive': 'minutes', - 'activities/tracker/minutesSedentary': 'minutes', - 'activities/tracker/minutesVeryActive': 'minutes', - 'activities/tracker/steps': 'steps', - 'body/bmi': 'BMI', - 'body/fat': '%', - 'devices/battery': 'level', - 'sleep/awakeningsCount': 'times awaken', - 'sleep/efficiency': '%', - 'sleep/minutesAfterWakeup': 'minutes', - 'sleep/minutesAsleep': 'minutes', - 'sleep/minutesAwake': 'minutes', - 'sleep/minutesToFallAsleep': 'minutes', - 'sleep/startTime': 'start time', - 'sleep/timeInBed': 'time in bed', - 'body/weight': '' + 'activities/activityCalories': ['Activity Calories', 'cal', 'fire'], + 'activities/calories': ['Calories', 'cal', 'fire'], + 'activities/caloriesBMR': ['Calories BMR', 'cal', 'fire'], + 'activities/distance': ['Distance', '', 'map-marker'], + 'activities/elevation': ['Elevation', '', 'walk'], + 'activities/floors': ['Floors', 'floors', 'walk'], + 'activities/heart': ['Resting Heart Rate', 'bpm', 'heart-pulse'], + 'activities/minutesFairlyActive': + ['Minutes Fairly Active', 'minutes', 'walk'], + 'activities/minutesLightlyActive': + ['Minutes Lightly Active', 'minutes', 'walk'], + 'activities/minutesSedentary': + ['Minutes Sedentary', 'minutes', 'seat-recline-normal'], + 'activities/minutesVeryActive': ['Minutes Very Active', 'minutes', 'run'], + 'activities/steps': ['Steps', 'steps', 'walk'], + 'activities/tracker/activityCalories': + ['Tracker Activity Calories', 'cal', 'fire'], + 'activities/tracker/calories': ['Tracker Calories', 'cal', 'fire'], + 'activities/tracker/distance': ['Tracker Distance', '', 'map-marker'], + 'activities/tracker/elevation': ['Tracker Elevation', '', 'walk'], + 'activities/tracker/floors': ['Tracker Floors', 'floors', 'walk'], + 'activities/tracker/minutesFairlyActive': + ['Tracker Minutes Fairly Active', 'minutes', 'walk'], + 'activities/tracker/minutesLightlyActive': + ['Tracker Minutes Lightly Active', 'minutes', 'walk'], + 'activities/tracker/minutesSedentary': + ['Tracker Minutes Sedentary', 'minutes', 'seat-recline-normal'], + 'activities/tracker/minutesVeryActive': + ['Tracker Minutes Very Active', 'minutes', 'run'], + 'activities/tracker/steps': ['Tracker Steps', 'steps', 'walk'], + 'body/bmi': ['BMI', 'BMI', 'human'], + 'body/fat': ['Body Fat', '%', 'human'], + 'body/weight': ['Weight', '', 'human'], + 'devices/battery': ['Battery', None, None], + 'sleep/awakeningsCount': + ['Awakenings Count', 'times awaken', 'sleep'], + 'sleep/efficiency': ['Sleep Efficiency', '%', 'sleep'], + 'sleep/minutesAfterWakeup': ['Minutes After Wakeup', 'minutes', 'sleep'], + 'sleep/minutesAsleep': ['Sleep Minutes Asleep', 'minutes', 'sleep'], + 'sleep/minutesAwake': ['Sleep Minutes Awake', 'minutes', 'sleep'], + 'sleep/minutesToFallAsleep': + ['Sleep Minutes to Fall Asleep', 'minutes', 'sleep'], + 'sleep/startTime': ['Sleep Start Time', None, 'clock'], + 'sleep/timeInBed': ['Sleep Time in Bed', 'minutes', 'hotel'] } FITBIT_MEASUREMENTS = { @@ -121,9 +132,18 @@ } } +BATTERY_LEVELS = { + 'High': 100, + 'Medium': 50, + 'Low': 20, + 'Empty': 0 +} + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_MONITORED_RESOURCES, default=FITBIT_DEFAULT_RESOURCES): vol.All(cv.ensure_list, [vol.In(FITBIT_RESOURCES_LIST)]), + vol.Optional(CONF_CLOCK_FORMAT, default='24H'): + vol.In(['12H', '24H']) }) @@ -155,7 +175,7 @@ def config_from_file(filename, config=None): def request_app_setup(hass, config, add_devices, config_path, discovery_info=None): """Assist user with configuring the Fitbit dev application.""" - configurator = get_component('configurator') + configurator = hass.components.configurator # pylint: disable=unused-argument def fitbit_configuration_callback(callback_data): @@ -166,7 +186,8 @@ def fitbit_configuration_callback(callback_data): if config_file == DEFAULT_CONFIG: error_msg = ("You didn't correctly modify fitbit.conf", " please try again") - configurator.notify_errors(_CONFIGURING['fitbit'], error_msg) + configurator.notify_errors(_CONFIGURING['fitbit'], + error_msg) else: setup_platform(hass, config, add_devices, discovery_info) else: @@ -187,7 +208,7 @@ def fitbit_configuration_callback(callback_data): submit = "I have saved my Client ID and Client Secret into fitbit.conf." _CONFIGURING['fitbit'] = configurator.request_config( - hass, 'Fitbit', fitbit_configuration_callback, + 'Fitbit', fitbit_configuration_callback, description=description, submit_caption=submit, description_image="/static/images/config_fitbit_app.png" ) @@ -195,7 +216,7 @@ def fitbit_configuration_callback(callback_data): def request_oauth_completion(hass): """Request user complete Fitbit OAuth2 flow.""" - configurator = get_component('configurator') + configurator = hass.components.configurator if "fitbit" in _CONFIGURING: configurator.notify_errors( _CONFIGURING['fitbit'], "Failed to register, please try again.") @@ -211,7 +232,7 @@ def fitbit_configuration_callback(callback_data): description = "Please authorize Fitbit by visiting {}".format(start_url) _CONFIGURING['fitbit'] = configurator.request_config( - hass, 'Fitbit', fitbit_configuration_callback, + 'Fitbit', fitbit_configuration_callback, description=description, submit_caption="I have authorized Fitbit." ) @@ -233,19 +254,22 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return False if "fitbit" in _CONFIGURING: - get_component('configurator').request_done(_CONFIGURING.pop("fitbit")) + hass.components.configurator.request_done(_CONFIGURING.pop("fitbit")) import fitbit access_token = config_file.get(ATTR_ACCESS_TOKEN) refresh_token = config_file.get(ATTR_REFRESH_TOKEN) + expires_at = config_file.get(ATTR_LAST_SAVED_AT) if None not in (access_token, refresh_token): authd_client = fitbit.Fitbit(config_file.get(ATTR_CLIENT_ID), config_file.get(ATTR_CLIENT_SECRET), access_token=access_token, - refresh_token=refresh_token) + refresh_token=refresh_token, + expires_at=expires_at, + refresh_cb=lambda x: None) - if int(time.time()) - config_file.get(ATTR_LAST_SAVED_AT, 0) > 3600: + if int(time.time()) - expires_at > 3600: authd_client.client.refresh_token() authd_client.system = authd_client.user_profile_get()["user"]["locale"] @@ -257,6 +281,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = [] registered_devs = authd_client.get_devices() + clock_format = config.get(CONF_CLOCK_FORMAT) for resource in config.get(CONF_MONITORED_RESOURCES): # monitor battery for all linked FitBit devices @@ -264,11 +289,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for dev_extra in registered_devs: dev.append(FitbitSensor( authd_client, config_path, resource, - hass.config.units.is_metric, dev_extra)) + hass.config.units.is_metric, clock_format, dev_extra)) else: dev.append(FitbitSensor( authd_client, config_path, resource, - hass.config.units.is_metric)) + hass.config.units.is_metric, clock_format)) add_devices(dev, True) else: @@ -316,12 +341,14 @@ def get(self, request): response_message = """Fitbit has been successfully authorized! You can close this window now!""" + result = None if data.get('code') is not None: redirect_uri = '{}{}'.format( hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH) try: - self.oauth.fetch_access_token(data.get('code'), redirect_uri) + result = self.oauth.fetch_access_token(data.get('code'), + redirect_uri) except MissingTokenError as error: _LOGGER.error("Missing token: %s", error) response_message = """Something went wrong when @@ -339,15 +366,23 @@ def get(self, request): An unknown error occurred. Please try again! """ + if result is None: + _LOGGER.error("Unknown error when authing") + response_message = """Something went wrong when + attempting authenticating with Fitbit. + An unknown error occurred. Please try again! + """ + html_response = """Fitbit Auth

{}

""".format(response_message) - config_contents = { - ATTR_ACCESS_TOKEN: self.oauth.token['access_token'], - ATTR_REFRESH_TOKEN: self.oauth.token['refresh_token'], - ATTR_CLIENT_ID: self.oauth.client_id, - ATTR_CLIENT_SECRET: self.oauth.client_secret - } + if result: + config_contents = { + ATTR_ACCESS_TOKEN: result.get('access_token'), + ATTR_REFRESH_TOKEN: result.get('refresh_token'), + ATTR_CLIENT_ID: self.oauth.client_id, + ATTR_CLIENT_SECRET: self.oauth.client_secret + } if not config_from_file(hass.config.path(FITBIT_CONFIG_FILE), config_contents): _LOGGER.error("Failed to save config file") @@ -361,34 +396,24 @@ class FitbitSensor(Entity): """Implementation of a Fitbit sensor.""" def __init__(self, client, config_path, resource_type, - is_metric, extra=None): + is_metric, clock_format, extra=None): """Initialize the Fitbit sensor.""" self.client = client self.config_path = config_path self.resource_type = resource_type + self.is_metric = is_metric + self.clock_format = clock_format self.extra = extra - pretty_resource = self.resource_type.replace('activities/', '') - pretty_resource = pretty_resource.replace('/', ' ') - pretty_resource = pretty_resource.title() - if pretty_resource == 'Body Bmi': - pretty_resource = 'BMI' - elif pretty_resource == 'Heart': - pretty_resource = 'Resting Heart Rate' - elif pretty_resource == 'Devices Battery': - if self.extra: - pretty_resource = \ - '{0} Battery'.format(self.extra.get('deviceVersion')) - else: - pretty_resource = 'Battery' - - self._name = pretty_resource - unit_type = FITBIT_RESOURCES_LIST[self.resource_type] + self._name = FITBIT_RESOURCES_LIST[self.resource_type][0] + if self.extra: + self._name = '{0} Battery'.format(self.extra.get('deviceVersion')) + unit_type = FITBIT_RESOURCES_LIST[self.resource_type][1] if unit_type == "": split_resource = self.resource_type.split('/') try: measurement_system = FITBIT_MEASUREMENTS[self.client.system] except KeyError: - if is_metric: + if self.is_metric: measurement_system = FITBIT_MEASUREMENTS['metric'] else: measurement_system = FITBIT_MEASUREMENTS['en_US'] @@ -414,9 +439,11 @@ def unit_of_measurement(self): @property def icon(self): """Icon to use in the frontend, if any.""" - if self.resource_type == 'devices/battery': - return 'mdi:battery-50' - return 'mdi:walk' + if self.resource_type == 'devices/battery' and self.extra: + battery_level = BATTERY_LEVELS[self.extra.get('battery')] + return icon_for_battery_level(battery_level=battery_level, + charging=None) + return 'mdi:{}'.format(FITBIT_RESOURCES_LIST[self.resource_type][2]) @property def device_state_attributes(self): @@ -427,7 +454,7 @@ def device_state_attributes(self): if self.extra: attrs['model'] = self.extra.get('deviceVersion') - attrs['type'] = self.extra.get('type') + attrs['type'] = self.extra.get('type').lower() return attrs @@ -438,14 +465,49 @@ def update(self): else: container = self.resource_type.replace("/", "-") response = self.client.time_series(self.resource_type, period='7d') - self._state = response[container][-1].get('value') + raw_state = response[container][-1].get('value') + if self.resource_type == 'activities/distance': + self._state = format(float(raw_state), '.2f') + elif self.resource_type == 'activities/tracker/distance': + self._state = format(float(raw_state), '.2f') + elif self.resource_type == 'body/bmi': + self._state = format(float(raw_state), '.1f') + elif self.resource_type == 'body/fat': + self._state = format(float(raw_state), '.1f') + elif self.resource_type == 'body/weight': + self._state = format(float(raw_state), '.1f') + elif self.resource_type == 'sleep/startTime': + if raw_state == '': + self._state = '-' + elif self.clock_format == '12H': + hours, minutes = raw_state.split(':') + hours, minutes = int(hours), int(minutes) + setting = 'AM' + if hours > 12: + setting = 'PM' + hours -= 12 + elif hours == 0: + hours = 12 + self._state = '{}:{} {}'.format(hours, minutes, setting) + else: + self._state = raw_state + else: + if self.is_metric: + self._state = raw_state + else: + try: + self._state = '{0:,}'.format(int(raw_state)) + except TypeError: + self._state = raw_state if self.resource_type == 'activities/heart': self._state = response[container][-1]. \ get('value').get('restingHeartRate') + + token = self.client.client.session.token config_contents = { - ATTR_ACCESS_TOKEN: self.client.client.token['access_token'], - ATTR_REFRESH_TOKEN: self.client.client.token['refresh_token'], + ATTR_ACCESS_TOKEN: token.get('access_token'), + ATTR_REFRESH_TOKEN: token.get('refresh_token'), ATTR_CLIENT_ID: self.client.client.client_id, ATTR_CLIENT_SECRET: self.client.client.client_secret, ATTR_LAST_SAVED_AT: int(time.time()) diff --git a/homeassistant/components/sensor/fritzbox_callmonitor.py b/homeassistant/components/sensor/fritzbox_callmonitor.py index a780d999b7e91..063a4808915b5 100644 --- a/homeassistant/components/sensor/fritzbox_callmonitor.py +++ b/homeassistant/components/sensor/fritzbox_callmonitor.py @@ -216,7 +216,7 @@ def _parse(self, line): self._sensor.set_attributes(att) elif line[1] == "CONNECT": self._sensor.set_state(VALUE_CONNECT) - att = {"with": line[4], "device": [3], "accepted": isotime} + att = {"with": line[4], "device": line[3], "accepted": isotime} att["with_name"] = self._sensor.number_to_name(att["with"]) self._sensor.set_attributes(att) elif line[1] == "DISCONNECT": diff --git a/homeassistant/components/sensor/geizhals.py b/homeassistant/components/sensor/geizhals.py index 5ba64dfa995b4..94f3f1884d188 100644 --- a/homeassistant/components/sensor/geizhals.py +++ b/homeassistant/components/sensor/geizhals.py @@ -80,6 +80,8 @@ def state(self): @property def device_state_attributes(self): """Return the state attributes.""" + while len(self.data.prices) < 4: + self.data.prices.append("None") attrs = {'device_name': self.data.device_name, 'description': self.description, 'unit_of_measurement': self.data.unit_of_measurement, diff --git a/homeassistant/components/sensor/homematic.py b/homeassistant/components/sensor/homematic.py index 771b4a94bd492..061fd27ca6978 100644 --- a/homeassistant/components/sensor/homematic.py +++ b/homeassistant/components/sensor/homematic.py @@ -57,8 +57,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMSensor(hass, conf) - new_device.link_homematic() + new_device = HMSensor(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/sensor/ios.py b/homeassistant/components/sensor/ios.py index c73e76ca75236..72377e07c7c92 100644 --- a/homeassistant/components/sensor/ios.py +++ b/homeassistant/components/sensor/ios.py @@ -6,6 +6,7 @@ """ from homeassistant.components import ios from homeassistant.helpers.entity import Entity +from homeassistant.util.icon import icon_for_battery_level DEPENDENCIES = ['ios'] @@ -83,44 +84,21 @@ def icon(self): device_battery = self._device[ios.ATTR_BATTERY] battery_state = device_battery[ios.ATTR_BATTERY_STATE] battery_level = device_battery[ios.ATTR_BATTERY_LEVEL] - rounded_level = round(battery_level, -1) - returning_icon_level = DEFAULT_ICON_LEVEL - if battery_state == ios.ATTR_BATTERY_STATE_FULL: - returning_icon_level = DEFAULT_ICON_LEVEL - if battery_state == ios.ATTR_BATTERY_STATE_CHARGING: - returning_icon_state = DEFAULT_ICON_STATE - else: - returning_icon_state = "{}-off".format(DEFAULT_ICON_STATE) - elif battery_state == ios.ATTR_BATTERY_STATE_CHARGING: - # Why is MDI missing 10, 50, 70? - if rounded_level in (20, 30, 40, 60, 80, 90, 100): - returning_icon_level = "{}-charging-{}".format( - DEFAULT_ICON_LEVEL, str(rounded_level)) - returning_icon_state = DEFAULT_ICON_STATE - else: - returning_icon_level = "{}-charging".format( - DEFAULT_ICON_LEVEL) - returning_icon_state = DEFAULT_ICON_STATE - elif battery_state == ios.ATTR_BATTERY_STATE_UNPLUGGED: - if rounded_level < 10: - returning_icon_level = "{}-outline".format( - DEFAULT_ICON_LEVEL) - returning_icon_state = "{}-off".format(DEFAULT_ICON_STATE) - elif battery_level > 95: - returning_icon_state = "{}-off".format(DEFAULT_ICON_STATE) - returning_icon_level = "{}-outline".format( - DEFAULT_ICON_LEVEL) - else: - returning_icon_level = "{}-{}".format(DEFAULT_ICON_LEVEL, - str(rounded_level)) - returning_icon_state = "{}-off".format(DEFAULT_ICON_STATE) + charging = True + icon_state = DEFAULT_ICON_STATE + if (battery_state == ios.ATTR_BATTERY_STATE_FULL or + battery_state == ios.ATTR_BATTERY_STATE_UNPLUGGED): + charging = False + icon_state = "{}-off".format(DEFAULT_ICON_STATE) elif battery_state == ios.ATTR_BATTERY_STATE_UNKNOWN: - returning_icon_level = "{}-unknown".format(DEFAULT_ICON_LEVEL) - returning_icon_state = "{}-unknown".format(DEFAULT_ICON_LEVEL) + battery_level = None + charging = False + icon_state = "{}-unknown".format(DEFAULT_ICON_LEVEL) if self.type == "state": - return returning_icon_state - return returning_icon_level + return icon_state + return icon_for_battery_level(battery_level=battery_level, + charging=charging) def update(self): """Get the latest state of the sensor.""" diff --git a/homeassistant/components/sensor/knx.py b/homeassistant/components/sensor/knx.py index 80a88ca925aa5..60f11d76e7913 100644 --- a/homeassistant/components/sensor/knx.py +++ b/homeassistant/components/sensor/knx.py @@ -1,184 +1,111 @@ """ -Sensors of a KNX Device. +Support for KNX/IP sensors. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/knx/ +https://home-assistant.io/components/sensor.knx/ """ -from enum import Enum - -import logging +import asyncio import voluptuous as vol -from homeassistant.const import ( - CONF_NAME, CONF_MAXIMUM, CONF_MINIMUM, - CONF_TYPE, TEMP_CELSIUS -) -from homeassistant.components.knx import (KNXConfig, KNXGroupAddress) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) +CONF_ADDRESS = 'address' +CONF_TYPE = 'type' +DEFAULT_NAME = 'KNX Sensor' DEPENDENCIES = ['knx'] -DEFAULT_NAME = "KNX sensor" - -CONF_TEMPERATURE = 'temperature' -CONF_ADDRESS = 'address' -CONF_ILLUMINANCE = 'illuminance' -CONF_PERCENTAGE = 'percentage' -CONF_SPEED_MS = 'speed_ms' - - -class KNXAddressType(Enum): - """Enum to indicate conversion type for the KNX address.""" - - FLOAT = 1 - PERCENT = 2 - - -# define the fixed settings required for each sensor type -FIXED_SETTINGS_MAP = { - # Temperature as defined in KNX Standard 3.10 - 9.001 DPT_Value_Temp - CONF_TEMPERATURE: { - 'unit': TEMP_CELSIUS, - 'default_minimum': -273, - 'default_maximum': 670760, - 'address_type': KNXAddressType.FLOAT - }, - # Speed m/s as defined in KNX Standard 3.10 - 9.005 DPT_Value_Wsp - CONF_SPEED_MS: { - 'unit': 'm/s', - 'default_minimum': 0, - 'default_maximum': 670760, - 'address_type': KNXAddressType.FLOAT - }, - # Luminance(LUX) as defined in KNX Standard 3.10 - 9.004 DPT_Value_Lux - CONF_ILLUMINANCE: { - 'unit': 'lx', - 'default_minimum': 0, - 'default_maximum': 670760, - 'address_type': KNXAddressType.FLOAT - }, - # Percentage(%) as defined in KNX Standard 3.10 - 5.001 DPT_Scaling - CONF_PERCENTAGE: { - 'unit': '%', - 'default_minimum': 0, - 'default_maximum': 100, - 'address_type': KNXAddressType.PERCENT - } -} - -SENSOR_TYPES = set(FIXED_SETTINGS_MAP.keys()) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), vol.Required(CONF_ADDRESS): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MINIMUM): vol.Coerce(float), - vol.Optional(CONF_MAXIMUM): vol.Coerce(float) + vol.Optional(CONF_TYPE): cv.string, }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX Sensor platform.""" - add_devices([KNXSensor(hass, KNXConfig(config))]) - - -class KNXSensor(KNXGroupAddress): - """Representation of a KNX Sensor device.""" - - def __init__(self, hass, config): - """Initialize a KNX Float Sensor.""" - # set up the KNX Group address - KNXGroupAddress.__init__(self, hass, config) - - device_type = config.config.get(CONF_TYPE) - sensor_config = FIXED_SETTINGS_MAP.get(device_type) - - if not sensor_config: - raise NotImplementedError() +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up sensor(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False - # set up the conversion function based on the address type - address_type = sensor_config.get('address_type') - if address_type == KNXAddressType.FLOAT: - self.convert = convert_float - elif address_type == KNXAddressType.PERCENT: - self.convert = convert_percent - else: - raise NotImplementedError() + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True + + +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up sensors for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXSensor(hass, device)) + add_devices(entities) + + +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up sensor for KNX platform configured within plattform.""" + import xknx + sensor = xknx.devices.Sensor( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address=config.get(CONF_ADDRESS), + value_type=config.get(CONF_TYPE)) + hass.data[DATA_KNX].xknx.devices.add(sensor) + add_devices([KNXSensor(hass, sensor)]) + + +class KNXSensor(Entity): + """Representation of a KNX sensor.""" + + def __init__(self, hass, device): + """Initialization of KNXSensor.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) - # other settings - self._unit_of_measurement = sensor_config.get('unit') - default_min = float(sensor_config.get('default_minimum')) - default_max = float(sensor_config.get('default_maximum')) - self._minimum_value = config.config.get(CONF_MINIMUM, default_min) - self._maximum_value = config.config.get(CONF_MAXIMUM, default_max) - _LOGGER.debug( - "%s: configured additional settings: unit=%s, " - "min=%f, max=%f, type=%s", - self.name, self._unit_of_measurement, - self._minimum_value, self._maximum_value, str(address_type) - ) + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name - self._value = None + @property + def should_poll(self): + """No polling needed within KNX.""" + return False @property def state(self): - """Return the Value of the KNX Sensor.""" - return self._value + """Return the state of the sensor.""" + return self.device.resolve_state() @property def unit_of_measurement(self): - """Return the defined Unit of Measurement for the KNX Sensor.""" - return self._unit_of_measurement - - def update(self): - """Update KNX sensor.""" - super().update() - - self._value = None - - if self._data: - if self._data == 0: - value = 0 - else: - value = self.convert(self._data) - if self._minimum_value <= value <= self._maximum_value: - self._value = value + """Return the unit this state is expressed in.""" + return self.device.unit_of_measurement() @property - def cache(self): - """We don't want to cache any Sensor Value.""" - return False - - -def convert_float(raw_value): - """Conversion for 2 byte floating point values. - - 2byte Floating Point KNX Telegram. - Defined in KNX 3.7.2 - 3.10 - """ - from knxip.conversion import knx2_to_float - from knxip.core import KNXException - - try: - return knx2_to_float(raw_value) - except KNXException as exception: - _LOGGER.error("Can't convert %s to float (%s)", raw_value, exception) - - -def convert_percent(raw_value): - """Conversion for scaled byte values. - - 1byte percentage scaled KNX Telegram. - Defined in KNX 3.7.2 - 3.10. - """ - value = 0 - try: - value = raw_value[0] - except (IndexError, ValueError): - # pknx returns a non-iterable type for unsuccessful reads - _LOGGER.error("Can't convert %s to percent value", raw_value) - - return round(value * 100 / 255) + def device_state_attributes(self): + """Return the state attributes.""" + return None diff --git a/homeassistant/components/sensor/london_air.py b/homeassistant/components/sensor/london_air.py new file mode 100644 index 0000000000000..7a8ad4087b010 --- /dev/null +++ b/homeassistant/components/sensor/london_air.py @@ -0,0 +1,216 @@ +""" +Sensor for checking the status of London air. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.london_air/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol +import requests + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import STATE_UNKNOWN +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +CONF_LOCATIONS = 'locations' +SCAN_INTERVAL = timedelta(minutes=30) +AUTHORITIES = [ + 'Barking and Dagenham', + 'Bexley', + 'Brent', + 'Camden', + 'City of London', + 'Croydon', + 'Ealing', + 'Enfield', + 'Greenwich', + 'Hackney', + 'Hammersmith and Fulham', + 'Haringey', + 'Harrow', + 'Havering', + 'Hillingdon', + 'Islington', + 'Kensington and Chelsea', + 'Kingston', + 'Lambeth', + 'Lewisham', + 'Merton', + 'Redbridge', + 'Richmond', + 'Southwark', + 'Sutton', + 'Tower Hamlets', + 'Wandsworth', + 'Westminster'] +URL = ('http://api.erg.kcl.ac.uk/AirQuality/Hourly/' + 'MonitoringIndex/GroupName=London/Json') + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_LOCATIONS, default=AUTHORITIES): + vol.All(cv.ensure_list, [vol.In(AUTHORITIES)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tube sensor.""" + data = APIData() + data.update() + sensors = [] + for name in config.get(CONF_LOCATIONS): + sensors.append(AirSensor(name, data)) + + add_devices(sensors, True) + + +class APIData(object): + """Get the latest data for all authorities.""" + + def __init__(self): + """Initialize the AirData object.""" + self.data = None + + # Update only once in scan interval. + @Throttle(SCAN_INTERVAL) + def update(self): + """Get the latest data from TFL.""" + response = requests.get(URL, timeout=10) + if response.status_code != 200: + _LOGGER.warning("Invalid response from API") + else: + self.data = parse_api_response(response.json()) + + +class AirSensor(Entity): + """Single authority air sensor.""" + + ICON = 'mdi:cloud-outline' + + def __init__(self, name, APIdata): + """Initialize the sensor.""" + self._name = name + self._api_data = APIdata + self._site_data = None + self._state = None + self._updated = 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 site_data(self): + """Return the dict of sites data.""" + return self._site_data + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self.ICON + + @property + def device_state_attributes(self): + """Return other details about the sensor state.""" + attrs = {} + attrs['updated'] = self._updated + attrs['sites'] = len(self._site_data) + attrs['data'] = self._site_data + return attrs + + def update(self): + """Update the sensor.""" + self._api_data.update() + self._site_data = self._api_data.data[self._name] + self._updated = self._site_data[0]['updated'] + sites_status = [] + for site in self._site_data: + if site['pollutants_status'] != 'no_species_data': + sites_status.append(site['pollutants_status']) + if sites_status: + self._state = max(set(sites_status), key=sites_status.count) + else: + self._state = STATE_UNKNOWN + + +def parse_species(species_data): + """Iterate over list of species at each site.""" + parsed_species_data = [] + quality_list = [] + for species in species_data: + if species['@AirQualityBand'] != 'No data': + species_dict = {} + species_dict['description'] = species['@SpeciesDescription'] + species_dict['code'] = species['@SpeciesCode'] + species_dict['quality'] = species['@AirQualityBand'] + species_dict['index'] = species['@AirQualityIndex'] + species_dict['summary'] = (species_dict['code'] + ' is ' + + species_dict['quality']) + parsed_species_data.append(species_dict) + quality_list.append(species_dict['quality']) + return parsed_species_data, quality_list + + +def parse_site(entry_sites_data): + """Iterate over all sites at an authority.""" + authority_data = [] + for site in entry_sites_data: + site_data = {} + species_data = [] + + site_data['updated'] = site['@BulletinDate'] + site_data['latitude'] = site['@Latitude'] + site_data['longitude'] = site['@Longitude'] + site_data['site_code'] = site['@SiteCode'] + site_data['site_name'] = site['@SiteName'].split("-")[-1].lstrip() + site_data['site_type'] = site['@SiteType'] + + if isinstance(site['Species'], dict): + species_data = [site['Species']] + else: + species_data = site['Species'] + + parsed_species_data, quality_list = parse_species(species_data) + + if not parsed_species_data: + parsed_species_data.append('no_species_data') + site_data['pollutants'] = parsed_species_data + + if quality_list: + site_data['pollutants_status'] = max(set(quality_list), + key=quality_list.count) + site_data['number_of_pollutants'] = len(quality_list) + else: + site_data['pollutants_status'] = 'no_species_data' + site_data['number_of_pollutants'] = 0 + + authority_data.append(site_data) + return authority_data + + +def parse_api_response(response): + """API can return dict or list of data so need to check.""" + data = dict.fromkeys(AUTHORITIES) + for authority in AUTHORITIES: + for entry in response['HourlyAirQualityIndex']['LocalAuthority']: + if entry['@LocalAuthorityName'] == authority: + + if isinstance(entry['Site'], dict): + entry_sites_data = [entry['Site']] + else: + entry_sites_data = entry['Site'] + + data[authority] = parse_site(entry_sites_data) + + return data diff --git a/homeassistant/components/sensor/mopar.py b/homeassistant/components/sensor/mopar.py new file mode 100644 index 0000000000000..0184cb2afdf4e --- /dev/null +++ b/homeassistant/components/sensor/mopar.py @@ -0,0 +1,165 @@ +""" +Sensor for Mopar vehicles. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.mopar/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_PIN, + ATTR_ATTRIBUTION, ATTR_COMMAND, + LENGTH_KILOMETERS) +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + + +REQUIREMENTS = ['motorparts==1.0.0'] + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(days=7) +DOMAIN = 'mopar' +ATTR_VEHICLE_INDEX = 'vehicle_index' +SERVICE_REMOTE_COMMAND = 'remote_command' +COOKIE_FILE = 'mopar_cookies.pickle' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_PIN): cv.positive_int +}) + +REMOTE_COMMAND_SCHEMA = vol.Schema({ + vol.Required(ATTR_COMMAND): cv.string, + vol.Required(ATTR_VEHICLE_INDEX): cv.positive_int +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Mopar platform.""" + import motorparts + cookie = hass.config.path(COOKIE_FILE) + try: + session = motorparts.get_session(config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + config.get(CONF_PIN), + cookie_path=cookie) + except motorparts.MoparError: + _LOGGER.error("failed to login") + return False + + def _handle_service(service): + """Handle service call.""" + index = service.data.get(ATTR_VEHICLE_INDEX) + command = service.data.get(ATTR_COMMAND) + try: + motorparts.remote_command(session, command, index) + except motorparts.MoparError as error: + _LOGGER.error(str(error)) + + hass.services.register(DOMAIN, SERVICE_REMOTE_COMMAND, _handle_service, + schema=REMOTE_COMMAND_SCHEMA) + + data = MoparData(session) + add_devices([MoparSensor(data, index) + for index, _ in enumerate(data.vehicles)], + True) + return True + + +# pylint: disable=too-few-public-methods +class MoparData(object): + """Container for Mopar vehicle data. + + Prevents session expiry re-login race condition. + """ + + def __init__(self, session): + """Initialize data.""" + self._session = session + self.vehicles = [] + self.vhrs = {} + self.tow_guides = {} + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, **kwargs): + """Update data.""" + import motorparts + _LOGGER.info("updating vehicle data") + try: + self.vehicles = motorparts.get_summary(self._session)['vehicles'] + except motorparts.MoparError: + _LOGGER.exception("failed to get summary") + return + for index, _ in enumerate(self.vehicles): + try: + self.vhrs[index] = motorparts.get_report(self._session, index) + self.tow_guides[index] = motorparts.get_tow_guide( + self._session, index) + except motorparts.MoparError: + _LOGGER.warning("failed to update for vehicle index %s", index) + + +class MoparSensor(Entity): + """Mopar vehicle sensor.""" + + def __init__(self, data, index): + """Initialize the sensor.""" + self._index = index + self._vehicle = {} + self._vhr = {} + self._tow_guide = {} + self._odometer = None + self._data = data + + def update(self): + """Update device state.""" + self._data.update() + self._vehicle = self._data.vehicles[self._index] + self._vhr = self._data.vhrs.get(self._index, {}) + self._tow_guide = self._data.tow_guides.get(self._index, {}) + if 'odometer' in self._vhr: + odo = float(self._vhr['odometer']) + self._odometer = int(self.hass.config.units.length( + odo, LENGTH_KILOMETERS)) + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {} {}'.format(self._vehicle['year'], + self._vehicle['make'], + self._vehicle['model']) + + @property + def state(self): + """Return the state of the sensor.""" + return self._odometer + + @property + def device_state_attributes(self): + """Return the state attributes.""" + import motorparts + attributes = { + ATTR_VEHICLE_INDEX: self._index, + ATTR_ATTRIBUTION: motorparts.ATTRIBUTION + } + attributes.update(self._vehicle) + attributes.update(self._vhr) + attributes.update(self._tow_guide) + return attributes + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self.hass.config.units.length_unit + + @property + def icon(self): + """Return the icon.""" + return 'mdi:car' diff --git a/homeassistant/components/sensor/mysensors.py b/homeassistant/components/sensor/mysensors.py index d46680c7b66b3..a8daf212e5734 100644 --- a/homeassistant/components/sensor/mysensors.py +++ b/homeassistant/components/sensor/mysensors.py @@ -4,89 +4,18 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.mysensors/ """ -import logging - from homeassistant.components import mysensors +from homeassistant.components.sensor import DOMAIN from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT -from homeassistant.helpers.entity import Entity - -_LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the MySensors platform for sensors.""" - # Only act if loaded via mysensors by discovery event. - # Otherwise gateway is not setup. - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - # Define the S_TYPES and V_TYPES that the platform should handle as - # states. Map them in a dict of lists. - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_TEMP: [set_req.V_TEMP], - pres.S_HUM: [set_req.V_HUM], - pres.S_BARO: [set_req.V_PRESSURE, set_req.V_FORECAST], - pres.S_WIND: [set_req.V_WIND, set_req.V_GUST, set_req.V_DIRECTION], - pres.S_RAIN: [set_req.V_RAIN, set_req.V_RAINRATE], - pres.S_UV: [set_req.V_UV], - pres.S_WEIGHT: [set_req.V_WEIGHT, set_req.V_IMPEDANCE], - pres.S_POWER: [set_req.V_WATT, set_req.V_KWH], - pres.S_DISTANCE: [set_req.V_DISTANCE], - pres.S_LIGHT_LEVEL: [set_req.V_LIGHT_LEVEL], - pres.S_IR: [set_req.V_IR_RECEIVE], - pres.S_WATER: [set_req.V_FLOW, set_req.V_VOLUME], - pres.S_CUSTOM: [set_req.V_VAR1, - set_req.V_VAR2, - set_req.V_VAR3, - set_req.V_VAR4, - set_req.V_VAR5], - pres.S_SCENE_CONTROLLER: [set_req.V_SCENE_ON, - set_req.V_SCENE_OFF], - } - if float(gateway.protocol_version) < 1.5: - map_sv_types.update({ - pres.S_AIR_QUALITY: [set_req.V_DUST_LEVEL], - pres.S_DUST: [set_req.V_DUST_LEVEL], - }) - if float(gateway.protocol_version) >= 1.5: - map_sv_types.update({ - pres.S_COLOR_SENSOR: [set_req.V_RGB], - pres.S_MULTIMETER: [set_req.V_VOLTAGE, - set_req.V_CURRENT, - set_req.V_IMPEDANCE], - pres.S_SOUND: [set_req.V_LEVEL], - pres.S_VIBRATION: [set_req.V_LEVEL], - pres.S_MOISTURE: [set_req.V_LEVEL], - pres.S_AIR_QUALITY: [set_req.V_LEVEL], - pres.S_DUST: [set_req.V_LEVEL], - }) - map_sv_types[pres.S_LIGHT_LEVEL].append(set_req.V_LEVEL) - - if float(gateway.protocol_version) >= 2.0: - map_sv_types.update({ - pres.S_INFO: [set_req.V_TEXT], - pres.S_GAS: [set_req.V_FLOW, set_req.V_VOLUME], - pres.S_GPS: [set_req.V_POSITION], - pres.S_WATER_QUALITY: [set_req.V_TEMP, set_req.V_PH, - set_req.V_ORP, set_req.V_EC] - }) - map_sv_types[pres.S_CUSTOM].append(set_req.V_CUSTOM) - map_sv_types[pres.S_POWER].extend( - [set_req.V_VAR, set_req.V_VA, set_req.V_POWER_FACTOR]) - - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, MySensorsSensor, add_devices)) + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsSensor, add_devices=add_devices) -class MySensorsSensor(mysensors.MySensorsDeviceEntity, Entity): +class MySensorsSensor(mysensors.MySensorsEntity): """Representation of a MySensors Sensor child node.""" @property diff --git a/homeassistant/components/sensor/netdata.py b/homeassistant/components/sensor/netdata.py index 6cb6ef9a14d3f..df6ff0b064916 100644 --- a/homeassistant/components/sensor/netdata.py +++ b/homeassistant/components/sensor/netdata.py @@ -38,11 +38,12 @@ 'running', 0], 'processes_blocked': ['Processes Blocked', 'Count', 'system.processes', 'blocked', 0], - 'system_load': ['System Load', '15 min', 'system.processes', 'running', 2], + 'system_load': ['System Load', '15 min', 'system.load', 'load15', 2], 'system_io_in': ['System IO In', 'Count', 'system.io', 'in', 0], 'system_io_out': ['System IO Out', 'Count', 'system.io', 'out', 0], 'ipv4_in': ['IPv4 In', 'kb/s', 'system.ipv4', 'received', 0], 'ipv4_out': ['IPv4 Out', 'kb/s', 'system.ipv4', 'sent', 0], + 'disk_free': ['Disk Free', 'GiB', 'disk_space._', 'avail', 2], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ diff --git a/homeassistant/components/sensor/octoprint.py b/homeassistant/components/sensor/octoprint.py index e3a06e583704b..85b388a1919b8 100644 --- a/homeassistant/components/sensor/octoprint.py +++ b/homeassistant/components/sensor/octoprint.py @@ -20,6 +20,8 @@ DEPENDENCIES = ['octoprint'] DOMAIN = "octoprint" DEFAULT_NAME = 'OctoPrint' +NOTIFICATION_ID = 'octoprint_notification' +NOTIFICATION_TITLE = 'OctoPrint sensor setup error' SENSOR_TYPES = { 'Temperatures': ['printer', 'temperature', '*', TEMP_CELSIUS], @@ -42,12 +44,26 @@ def setup_platform(hass, config, add_devices, discovery_info=None): octoprint_api = hass.data[DOMAIN]["api"] name = config.get(CONF_NAME) monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) + tools = octoprint_api.get_tools() + _LOGGER.error(str(tools)) + + if "Temperatures" in monitored_conditions: + if not tools: + hass.components.persistent_notification.create( + 'Your printer appears to be offline.
' + 'If you do not want to have your printer on
' + ' at all times, and you would like to monitor
' + 'temperatures, please add
' + 'bed and/or number_of_tools to your config
' + 'and restart.', + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) devices = [] types = ["actual", "target"] for octo_type in monitored_conditions: if octo_type == "Temperatures": - for tool in octoprint_api.get_tools(): + for tool in tools: for temp_type in types: new_sensor = OctoPrintSensor( octoprint_api, temp_type, temp_type, name, diff --git a/homeassistant/components/sensor/radarr.py b/homeassistant/components/sensor/radarr.py index 03fbce3e79abd..33a09a51aefd3 100644 --- a/homeassistant/components/sensor/radarr.py +++ b/homeassistant/components/sensor/radarr.py @@ -162,7 +162,7 @@ def update(self): res = requests.get( ENDPOINTS[self.type].format( self.ssl, self.host, self.port, self.urlbase, start, end), - headers={'X-Api-Key': self.apikey}, timeout=5) + headers={'X-Api-Key': self.apikey}, timeout=10) except OSError: _LOGGER.error("Host %s is not available", self.host) self._available = False diff --git a/homeassistant/components/sensor/sabnzbd.py b/homeassistant/components/sensor/sabnzbd.py index e2b7584d8653e..928e855915a52 100644 --- a/homeassistant/components/sensor/sabnzbd.py +++ b/homeassistant/components/sensor/sabnzbd.py @@ -18,7 +18,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -from homeassistant.loader import get_component REQUIREMENTS = ['https://github.com/jamespcole/home-assistant-nzb-clients/' 'archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip' @@ -88,7 +87,7 @@ def setup_sabnzbd(base_url, apikey, name, hass, config, add_devices, sab_api): def request_configuration(host, name, hass, config, add_devices, sab_api): """Request configuration steps from the user.""" - configurator = get_component('configurator') + configurator = hass.components.configurator # We got an error if this method is called while we are configuring if host in _CONFIGURING: configurator.notify_errors(_CONFIGURING[host], @@ -114,7 +113,6 @@ def success(): hass.async_add_job(success) _CONFIGURING[host] = configurator.request_config( - hass, DEFAULT_NAME, sabnzbd_configuration_callback, description=('Enter the API Key'), diff --git a/homeassistant/components/sensor/season.py b/homeassistant/components/sensor/season.py new file mode 100644 index 0000000000000..e02f3cac2b04c --- /dev/null +++ b/homeassistant/components/sensor/season.py @@ -0,0 +1,122 @@ +""" +Support for tracking which astronomical or meteorological season it is. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor/season/ +""" +import logging +from datetime import datetime + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_TYPE +from homeassistant.helpers.entity import Entity +import homeassistant.util as util + +REQUIREMENTS = ['ephem==3.7.6.0'] + +_LOGGER = logging.getLogger(__name__) + +NORTHERN = 'northern' +SOUTHERN = 'southern' +EQUATOR = 'equator' +STATE_SPRING = 'Spring' +STATE_SUMMER = 'Summer' +STATE_AUTUMN = 'Autumn' +STATE_WINTER = 'Winter' +TYPE_ASTRONOMICAL = 'astronomical' +TYPE_METEOROLOGICAL = 'meteorological' +VALID_TYPES = [TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL] + +HEMISPHERE_SEASON_SWAP = {STATE_WINTER: STATE_SUMMER, + STATE_SPRING: STATE_AUTUMN, + STATE_AUTUMN: STATE_SPRING, + STATE_SUMMER: STATE_WINTER} + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_TYPE, default=TYPE_ASTRONOMICAL): vol.In(VALID_TYPES) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Display the current season.""" + if None in (hass.config.latitude, hass.config.longitude): + _LOGGER.error("Latitude or longitude not set in Home Assistant config") + return False + + latitude = util.convert(hass.config.latitude, float) + _type = config.get(CONF_TYPE) + + if latitude < 0: + hemisphere = SOUTHERN + elif latitude > 0: + hemisphere = NORTHERN + else: + hemisphere = EQUATOR + + _LOGGER.debug(_type) + add_devices([Season(hass, hemisphere, _type)]) + + return True + + +def get_season(date, hemisphere, season_tracking_type): + """Calculate the current season.""" + import ephem + + if hemisphere == 'equator': + return None + + if season_tracking_type == TYPE_ASTRONOMICAL: + spring_start = ephem.next_equinox(str(date.year)).datetime() + summer_start = ephem.next_solstice(str(date.year)).datetime() + autumn_start = ephem.next_equinox(spring_start).datetime() + winter_start = ephem.next_solstice(summer_start).datetime() + else: + spring_start = datetime(2017, 3, 1).replace(year=date.year) + summer_start = spring_start.replace(month=6) + autumn_start = spring_start.replace(month=9) + winter_start = spring_start.replace(month=12) + + if spring_start <= date < summer_start: + season = STATE_SPRING + elif summer_start <= date < autumn_start: + season = STATE_SUMMER + elif autumn_start <= date < winter_start: + season = STATE_AUTUMN + elif winter_start <= date or spring_start > date: + season = STATE_WINTER + + # If user is located in the southern hemisphere swap the season + if hemisphere == NORTHERN: + return season + return HEMISPHERE_SEASON_SWAP.get(season) + + +class Season(Entity): + """Representation of the current season.""" + + def __init__(self, hass, hemisphere, season_tracking_type): + """Initialize the season.""" + self.hass = hass + self.hemisphere = hemisphere + self.datetime = datetime.now() + self.type = season_tracking_type + self.season = get_season(self.datetime, self.hemisphere, self.type) + + @property + def name(self): + """Return the name.""" + return "Season" + + @property + def state(self): + """Return the current season.""" + return self.season + + def update(self): + """Update season.""" + self.datetime = datetime.now() + self.season = get_season(self.datetime, self.hemisphere, self.type) diff --git a/homeassistant/components/sensor/shodan.py b/homeassistant/components/sensor/shodan.py index c95d975ec4777..3d86d940f4d02 100644 --- a/homeassistant/components/sensor/shodan.py +++ b/homeassistant/components/sensor/shodan.py @@ -14,7 +14,7 @@ from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['shodan==1.7.4'] +REQUIREMENTS = ['shodan==1.7.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index 361ce55142678..aeb4587f3df7b 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -13,7 +13,8 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PORT, CONF_UNIT_OF_MEASUREMENT) + CONF_HOST, CONF_NAME, CONF_PORT, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, + CONF_VALUE_TEMPLATE) REQUIREMENTS = ['pysnmp==4.3.9'] @@ -22,6 +23,8 @@ CONF_BASEOID = 'baseoid' CONF_COMMUNITY = 'community' CONF_VERSION = 'version' +CONF_ACCEPT_ERRORS = 'accept_errors' +CONF_DEFAULT_VALUE = 'default_value' DEFAULT_COMMUNITY = 'public' DEFAULT_HOST = 'localhost' @@ -45,6 +48,9 @@ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.In(SNMP_VERSIONS), + vol.Optional(CONF_ACCEPT_ERRORS, default=False): cv.boolean, + vol.Optional(CONF_DEFAULT_VALUE): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template }) @@ -61,6 +67,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): baseoid = config.get(CONF_BASEOID) unit = config.get(CONF_UNIT_OF_MEASUREMENT) version = config.get(CONF_VERSION) + accept_errors = config.get(CONF_ACCEPT_ERRORS) + default_value = config.get(CONF_DEFAULT_VALUE) + value_template = config.get(CONF_VALUE_TEMPLATE) + + if value_template is not None: + value_template.hass = hass errindication, _, _, _ = next( getCmd(SnmpEngine(), @@ -69,23 +81,27 @@ def setup_platform(hass, config, add_devices, discovery_info=None): ContextData(), ObjectType(ObjectIdentity(baseoid)))) - if errindication: + if errindication and not accept_errors: _LOGGER.error("Please check the details in the configuration file") return False else: - data = SnmpData(host, port, community, baseoid, version) - add_devices([SnmpSensor(data, name, unit)], True) + data = SnmpData( + host, port, community, baseoid, version, accept_errors, + default_value) + add_devices([SnmpSensor(data, name, unit, value_template)], True) class SnmpSensor(Entity): """Representation of a SNMP sensor.""" - def __init__(self, data, name, unit_of_measurement): + def __init__(self, data, name, unit_of_measurement, + value_template): """Initialize the sensor.""" self.data = data self._name = name self._state = None self._unit_of_measurement = unit_of_measurement + self._value_template = value_template @property def name(self): @@ -105,19 +121,30 @@ def unit_of_measurement(self): def update(self): """Get the latest data and updates the states.""" self.data.update() - self._state = self.data.value + value = self.data.value + + if value is None: + value = STATE_UNKNOWN + elif self._value_template is not None: + value = self._value_template.render_with_possible_json_value( + value, STATE_UNKNOWN) + + self._state = value class SnmpData(object): """Get the latest data and update the states.""" - def __init__(self, host, port, community, baseoid, version): + def __init__(self, host, port, community, baseoid, version, accept_errors, + default_value): """Initialize the data object.""" self._host = host self._port = port self._community = community self._baseoid = baseoid self._version = SNMP_VERSIONS[version] + self._accept_errors = accept_errors + self._default_value = default_value self.value = None def update(self): @@ -133,11 +160,13 @@ def update(self): ObjectType(ObjectIdentity(self._baseoid))) ) - if errindication: + if errindication and not self._accept_errors: _LOGGER.error("SNMP error: %s", errindication) - elif errstatus: + elif errstatus and not self._accept_errors: _LOGGER.error("SNMP error: %s at %s", errstatus.prettyPrint(), errindex and restable[-1][int(errindex) - 1] or '?') + elif (errindication or errstatus) and self._accept_errors: + self.value = self._default_value else: for resrow in restable: - self.value = resrow[-1] + self.value = str(resrow[-1]) diff --git a/homeassistant/components/sensor/sonarr.py b/homeassistant/components/sensor/sonarr.py index 143fcee0a61f9..4be5582b8c4cb 100644 --- a/homeassistant/components/sensor/sonarr.py +++ b/homeassistant/components/sensor/sonarr.py @@ -36,17 +36,19 @@ 'upcoming': ['Upcoming', 'Episodes', 'mdi:television'], 'wanted': ['Wanted', 'Episodes', 'mdi:television'], 'series': ['Series', 'Shows', 'mdi:television'], - 'commands': ['Commands', 'Commands', 'mdi:code-braces'] + 'commands': ['Commands', 'Commands', 'mdi:code-braces'], + 'status': ['Status', 'Status', 'mdi:information'] } ENDPOINTS = { - 'diskspace': 'http{0}://{1}:{2}/{3}api/diskspace?apikey={4}', - 'queue': 'http{0}://{1}:{2}/{3}api/queue?apikey={4}', + 'diskspace': 'http{0}://{1}:{2}/{3}api/diskspace', + 'queue': 'http{0}://{1}:{2}/{3}api/queue', 'upcoming': - 'http{0}://{1}:{2}/{3}api/calendar?apikey={4}&start={5}&end={6}', - 'wanted': 'http{0}://{1}:{2}/{3}api/wanted/missing?apikey={4}', - 'series': 'http{0}://{1}:{2}/{3}api/series?apikey={4}', - 'commands': 'http{0}://{1}:{2}/{3}api/command?apikey={4}' + 'http{0}://{1}:{2}/{3}api/calendar?start={4}&end={5}', + 'wanted': 'http{0}://{1}:{2}/{3}api/wanted/missing', + 'series': 'http{0}://{1}:{2}/{3}api/series', + 'commands': 'http{0}://{1}:{2}/{3}api/command', + 'status': 'http{0}://{1}:{2}/{3}api/system/status' } # Support to Yottabytes for the future, why not @@ -156,6 +158,8 @@ def device_state_attributes(self): for show in self.data: attributes[show['title']] = '{}/{} Episodes'.format( show['episodeFileCount'], show['episodeCount']) + elif self.type == 'status': + attributes = self.data return attributes @property @@ -168,9 +172,12 @@ def update(self): start = get_date(self._tz) end = get_date(self._tz, self.days) try: - res = requests.get(ENDPOINTS[self.type].format( - self.ssl, self.host, self.port, self.urlbase, self.apikey, - start, end), timeout=5) + res = requests.get( + ENDPOINTS[self.type].format( + self.ssl, self.host, self.port, + self.urlbase, start, end), + headers={'X-Api-Key': self.apikey}, + timeout=10) except OSError: _LOGGER.error("Host %s is not available", self.host) self._available = False @@ -193,10 +200,13 @@ def update(self): self._state = len(self.data) elif self.type == 'wanted': data = res.json() - res = requests.get('{}&pageSize={}'.format( - ENDPOINTS[self.type].format( - self.ssl, self.host, self.port, self.urlbase, - self.apikey), data['totalRecords']), timeout=5) + res = requests.get( + '{}?pageSize={}'.format( + ENDPOINTS[self.type].format( + self.ssl, self.host, self.port, self.urlbase), + data['totalRecords']), + headers={'X-Api-Key': self.apikey}, + timeout=10) self.data = res.json()['records'] self._state = len(self.data) elif self.type == 'diskspace': @@ -217,6 +227,9 @@ def update(self): self._unit ) ) + elif self.type == 'status': + self.data = res.json() + self._state = self.data['version'] self._available = True diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index 2d7b74e87912c..34d3cabf26ba7 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -18,6 +18,7 @@ from homeassistant.core import callback from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -34,6 +35,8 @@ ATTR_TOTAL = 'total' CONF_SAMPLING_SIZE = 'sampling_size' +CONF_MAX_AGE = 'max_age' + DEFAULT_NAME = 'Stats' DEFAULT_SIZE = 20 ICON = 'mdi:calculator' @@ -41,7 +44,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SAMPLING_SIZE, default=DEFAULT_SIZE): cv.positive_int, + vol.Optional(CONF_SAMPLING_SIZE, default=DEFAULT_SIZE): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_MAX_AGE): cv.time_period }) @@ -51,16 +56,18 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): entity_id = config.get(CONF_ENTITY_ID) name = config.get(CONF_NAME) sampling_size = config.get(CONF_SAMPLING_SIZE) + max_age = config.get(CONF_MAX_AGE, None) async_add_devices( - [StatisticsSensor(hass, entity_id, name, sampling_size)], True) + [StatisticsSensor(hass, entity_id, name, sampling_size, max_age)], + True) return True class StatisticsSensor(Entity): """Representation of a Statistics sensor.""" - def __init__(self, hass, entity_id, name, sampling_size): + def __init__(self, hass, entity_id, name, sampling_size, max_age): """Initialize the Statistics sensor.""" self._hass = hass self._entity_id = entity_id @@ -71,11 +78,12 @@ def __init__(self, hass, entity_id, name, sampling_size): else: self._name = '{} {}'.format(name, ATTR_COUNT) self._sampling_size = sampling_size + self._max_age = max_age self._unit_of_measurement = None - if self._sampling_size == 0: - self.states = deque() - else: - self.states = deque(maxlen=self._sampling_size) + self.states = deque(maxlen=self._sampling_size) + if self._max_age is not None: + self.ages = deque(maxlen=self._sampling_size) + self.median = self.mean = self.variance = self.stdev = 0 self.min = self.max = self.total = self.count = 0 self.average_change = self.change = 0 @@ -89,6 +97,9 @@ def async_stats_sensor_state_listener(entity, old_state, new_state): try: self.states.append(float(new_state.state)) + if self._max_age is not None: + now = dt_util.utcnow() + self.ages.append(now) self.count = self.count + 1 except ValueError: self.count = self.count + 1 @@ -128,8 +139,7 @@ def device_state_attributes(self): ATTR_MAX_VALUE: self.max, ATTR_MEDIAN: self.median, ATTR_MIN_VALUE: self.min, - ATTR_SAMPLING_SIZE: 'unlimited' if self._sampling_size is - 0 else self._sampling_size, + ATTR_SAMPLING_SIZE: self._sampling_size, ATTR_STANDARD_DEVIATION: self.stdev, ATTR_TOTAL: self.total, ATTR_VARIANCE: self.variance, @@ -142,9 +152,20 @@ def icon(self): """Return the icon to use in the frontend, if any.""" return ICON + def _purge_old(self): + """Remove states which are older than self._max_age.""" + now = dt_util.utcnow() + + while (len(self.ages) > 0) and (now - self.ages[0]) > self._max_age: + self.ages.popleft() + self.states.popleft() + @asyncio.coroutine def async_update(self): """Get the latest data and updates the states.""" + if self._max_age is not None: + self._purge_old() + if not self.is_binary: try: self.mean = round(statistics.mean(self.states), 2) diff --git a/homeassistant/components/sensor/swiss_public_transport.py b/homeassistant/components/sensor/swiss_public_transport.py index aa0be36b075ff..0febd8c95bcb0 100644 --- a/homeassistant/components/sensor/swiss_public_transport.py +++ b/homeassistant/components/sensor/swiss_public_transport.py @@ -136,7 +136,7 @@ def update(self): 'fields[]=connections/from/departureTimestamp/&' + 'fields[]=connections/', timeout=10) - connections = response.json()['connections'][:2] + connections = response.json()['connections'][1:3] try: self.times = [ diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 42229351fde9e..69a82fb0faced 100755 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -16,10 +16,12 @@ import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['psutil==5.2.2'] +REQUIREMENTS = ['psutil==5.3.0'] _LOGGER = logging.getLogger(__name__) +CONF_ARG = 'arg' + SENSOR_TYPES = { 'disk_free': ['Disk Free', 'GiB', 'mdi:harddisk'], 'disk_use': ['Disk Use', 'GiB', 'mdi:harddisk'], @@ -49,7 +51,7 @@ vol.Optional(CONF_RESOURCES, default=['disk_use']): vol.All(cv.ensure_list, [vol.Schema({ vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), - vol.Optional('arg'): cv.string, + vol.Optional(CONF_ARG): cv.string, })]) }) @@ -71,9 +73,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the system monitor sensors.""" dev = [] for resource in config[CONF_RESOURCES]: - if 'arg' not in resource: - resource['arg'] = '' - dev.append(SystemMonitorSensor(resource[CONF_TYPE], resource['arg'])) + if CONF_ARG not in resource: + resource[CONF_ARG] = '' + dev.append(SystemMonitorSensor( + resource[CONF_TYPE], resource[CONF_ARG])) add_devices(dev, True) diff --git a/homeassistant/components/sensor/tank_utility.py b/homeassistant/components/sensor/tank_utility.py new file mode 100644 index 0000000000000..01ace41515979 --- /dev/null +++ b/homeassistant/components/sensor/tank_utility.py @@ -0,0 +1,138 @@ +""" +Support for the Tank Utility propane monitor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.tank_utility/ +""" + +import datetime +import logging + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_DEVICES, CONF_EMAIL, CONF_PASSWORD, + STATE_UNKNOWN) +from homeassistant.helpers.entity import Entity + + +REQUIREMENTS = [ + "tank_utility==1.4.0" +] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = datetime.timedelta(hours=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, vol.Length(min=1)) +}) + +SENSOR_TYPE = "tank" +SENSOR_ROUNDING_PRECISION = 1 +SENSOR_UNIT_OF_MEASUREMENT = "%" +SENSOR_ATTRS = [ + "name", + "address", + "capacity", + "fuelType", + "orientation", + "status", + "time", + "time_iso" +] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tank Utility sensor.""" + from tank_utility import auth + email = config.get(CONF_EMAIL) + password = config.get(CONF_PASSWORD) + devices = config.get(CONF_DEVICES) + + try: + token = auth.get_token(email, password) + except requests.exceptions.HTTPError as http_error: + if (http_error.response.status_code == + requests.codes.unauthorized): # pylint: disable=no-member + _LOGGER.error("Invalid credentials") + return + + all_sensors = [] + for device in devices: + sensor = TankUtilitySensor(email, password, token, device) + all_sensors.append(sensor) + add_devices(all_sensors, True) + + +class TankUtilitySensor(Entity): + """Representation of a Tank Utility sensor.""" + + def __init__(self, email, password, token, device): + """Initialize the sensor.""" + self._email = email + self._password = password + self._token = token + self._device = device + self._state = STATE_UNKNOWN + self._name = "Tank Utility " + self.device + self._unit_of_measurement = SENSOR_UNIT_OF_MEASUREMENT + self._attributes = {} + + @property + def device(self): + """Return the device identifier.""" + return self._device + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement of the device.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the attributes of the device.""" + return self._attributes + + def get_data(self): + """Get data from the device. + + Flatten dictionary to map device to map of device data. + + """ + from tank_utility import auth, device + data = {} + try: + data = device.get_device_data(self._token, self.device) + except requests.exceptions.HTTPError as http_error: + if (http_error.response.status_code == + requests.codes.unauthorized): # pylint: disable=no-member + _LOGGER.info("Getting new token") + self._token = auth.get_token(self._email, self._password, + force=True) + data = device.get_device_data(self._token, self.device) + else: + raise http_error + data.update(data.pop("device", {})) + data.update(data.pop("lastReading", {})) + return data + + def update(self): + """Set the device state and attributes.""" + data = self.get_data() + self._state = round(data[SENSOR_TYPE], SENSOR_ROUNDING_PRECISION) + self._attributes = {k: v for k, v in data.items() if k in SENSOR_ATTRS} diff --git a/homeassistant/components/sensor/tesla.py b/homeassistant/components/sensor/tesla.py new file mode 100644 index 0000000000000..fc31a5543e2c8 --- /dev/null +++ b/homeassistant/components/sensor/tesla.py @@ -0,0 +1,82 @@ +""" +Sensors for the Tesla sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.tesla/ +""" +import logging +from datetime import timedelta + +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + +SCAN_INTERVAL = timedelta(minutes=5) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla sensor platform.""" + controller = hass.data[TESLA_DOMAIN]['devices']['controller'] + devices = [] + + for device in hass.data[TESLA_DOMAIN]['devices']['sensor']: + if device.bin_type == 0x4: + devices.append(TeslaSensor(device, controller, 'inside')) + devices.append(TeslaSensor(device, controller, 'outside')) + else: + devices.append(TeslaSensor(device, controller)) + add_devices(devices, True) + + +class TeslaSensor(TeslaDevice, Entity): + """Representation of Tesla sensors.""" + + def __init__(self, tesla_device, controller, sensor_type=None): + """Initialisation of the sensor.""" + self.current_value = None + self._temperature_units = None + self.last_changed_time = None + self.type = sensor_type + super().__init__(tesla_device, controller) + + if self.type: + self._name = '{} ({})'.format(self.tesla_device.name, self.type) + self.entity_id = ENTITY_ID_FORMAT.format( + '{}_{}'.format(self.tesla_id, self.type)) + else: + self._name = self.tesla_device.name + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + + @property + def state(self): + """Return the state of the sensor.""" + return self.current_value + + @property + def unit_of_measurement(self): + """Return the unit_of_measurement of the device.""" + return self._temperature_units + + def update(self): + """Update the state from the sensor.""" + _LOGGER.debug("Updating sensor: %s", self._name) + self.tesla_device.update() + if self.tesla_device.bin_type == 0x4: + if self.type == 'outside': + self.current_value = self.tesla_device.get_outside_temp() + else: + self.current_value = self.tesla_device.get_inside_temp() + + tesla_temp_units = self.tesla_device.measurement + + if tesla_temp_units == 'F': + self._temperature_units = TEMP_FAHRENHEIT + else: + self._temperature_units = TEMP_CELSIUS + else: + self.current_value = self.tesla_device.battery_level() diff --git a/homeassistant/components/sensor/uber.py b/homeassistant/components/sensor/uber.py index 17ce103624464..eb7050309bc8a 100644 --- a/homeassistant/components/sensor/uber.py +++ b/homeassistant/components/sensor/uber.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['uber_rides==0.4.1'] +REQUIREMENTS = ['uber_rides==0.5.2'] _LOGGER = logging.getLogger(__name__) @@ -87,11 +87,14 @@ def __init__(self, sensorType, products, product_id, product): if self._product.get('price_details') is not None: price_details = self._product['price_details'] self._unit_of_measurement = price_details.get('currency_code') - if price_details.get('low_estimate') is not None: - statekey = 'minimum' - else: - statekey = 'low_estimate' - self._state = int(price_details.get(statekey, 0)) + try: + if price_details.get('low_estimate') is not None: + statekey = 'minimum' + else: + statekey = 'low_estimate' + self._state = int(price_details.get(statekey)) + except TypeError: + self._state = 0 else: self._state = 0 diff --git a/homeassistant/components/sensor/uk_transport.py b/homeassistant/components/sensor/uk_transport.py index b9ce98ec25743..bcac4b4727973 100644 --- a/homeassistant/components/sensor/uk_transport.py +++ b/homeassistant/components/sensor/uk_transport.py @@ -180,9 +180,12 @@ def _update(self): 'estimated': departure['best_departure_estimate'] }) - self._state = min(map( - _delta_mins, [bus['scheduled'] for bus in self._next_buses] - )) + if self._next_buses: + self._state = min( + _delta_mins(bus['scheduled']) + for bus in self._next_buses) + else: + self._state = None @property def device_state_attributes(self): @@ -242,10 +245,12 @@ def _update(self): 'operator_name': departure['operator_name'] }) - self._state = min(map( - _delta_mins, - [train['scheduled'] for train in self._next_trains] - )) + if self._next_trains: + self._state = min( + _delta_mins(train['scheduled']) + for train in self._next_trains) + else: + self._state = None @property def device_state_attributes(self): diff --git a/homeassistant/components/sensor/usps.py b/homeassistant/components/sensor/usps.py index 1e818587a72d9..322c27e2f3716 100644 --- a/homeassistant/components/sensor/usps.py +++ b/homeassistant/components/sensor/usps.py @@ -6,65 +6,44 @@ """ from collections import defaultdict import logging -from datetime import timedelta -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_NAME, CONF_USERNAME, CONF_PASSWORD, - ATTR_ATTRIBUTION) +from homeassistant.components.usps import DATA_USPS +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DATE from homeassistant.helpers.entity import Entity from homeassistant.util import slugify from homeassistant.util.dt import now, parse_datetime -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['myusps==1.1.2'] _LOGGER = logging.getLogger(__name__) -DOMAIN = 'usps' -SCAN_INTERVAL = timedelta(minutes=30) -COOKIE = 'usps_cookies.pickle' -STATUS_DELIVERED = 'delivered' +DEPENDENCIES = ['usps'] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME): cv.string -}) +STATUS_DELIVERED = 'delivered' -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the USPS platform.""" - import myusps - try: - cookie = hass.config.path(COOKIE) - session = myusps.get_session( - config.get(CONF_USERNAME), config.get(CONF_PASSWORD), - cookie_path=cookie) - except myusps.USPSError: - _LOGGER.exception('Could not connect to My USPS') - return False + if discovery_info is None: + return - add_devices([USPSPackageSensor(session, config.get(CONF_NAME)), - USPSMailSensor(session, config.get(CONF_NAME))], True) + usps = hass.data[DATA_USPS] + add_devices([USPSPackageSensor(usps), + USPSMailSensor(usps)], True) class USPSPackageSensor(Entity): """USPS Package Sensor.""" - def __init__(self, session, name): + def __init__(self, usps): """Initialize the sensor.""" - self._session = session - self._name = name + self._usps = usps + self._name = self._usps.name self._attributes = None self._state = None @property def name(self): """Return the name of the sensor.""" - return '{} packages'.format(self._name or DOMAIN) + return '{} packages'.format(self._name) @property def state(self): @@ -73,16 +52,16 @@ def state(self): def update(self): """Update device state.""" - import myusps + self._usps.update() status_counts = defaultdict(int) - for package in myusps.get_packages(self._session): + for package in self._usps.packages: status = slugify(package['primary_status']) if status == STATUS_DELIVERED and \ parse_datetime(package['date']).date() < now().date(): continue status_counts[status] += 1 self._attributes = { - ATTR_ATTRIBUTION: myusps.ATTRIBUTION + ATTR_ATTRIBUTION: self._usps.attribution } self._attributes.update(status_counts) self._state = sum(status_counts.values()) @@ -97,21 +76,26 @@ def icon(self): """Icon to use in the frontend.""" return 'mdi:package-variant-closed' + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return 'packages' + class USPSMailSensor(Entity): """USPS Mail Sensor.""" - def __init__(self, session, name): + def __init__(self, usps): """Initialize the sensor.""" - self._session = session - self._name = name + self._usps = usps + self._name = self._usps.name self._attributes = None self._state = None @property def name(self): """Return the name of the sensor.""" - return '{} mail'.format(self._name or DOMAIN) + return '{} mail'.format(self._name) @property def state(self): @@ -120,18 +104,29 @@ def state(self): def update(self): """Update device state.""" - import myusps - self._state = len(myusps.get_mail(self._session)) + self._usps.update() + if self._usps.mail is not None: + self._state = len(self._usps.mail) + else: + self._state = 0 @property def device_state_attributes(self): """Return the state attributes.""" - import myusps - return { - ATTR_ATTRIBUTION: myusps.ATTRIBUTION - } + attr = {} + attr[ATTR_ATTRIBUTION] = self._usps.attribution + try: + attr[ATTR_DATE] = self._usps.mail[0]['date'] + except IndexError: + pass + return attr @property def icon(self): """Icon to use in the frontend.""" return 'mdi:mailbox' + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return 'pieces' diff --git a/homeassistant/components/sensor/version.py b/homeassistant/components/sensor/version.py new file mode 100644 index 0000000000000..c19d274356349 --- /dev/null +++ b/homeassistant/components/sensor/version.py @@ -0,0 +1,55 @@ +""" +Support for displaying the current version of Home Assistant. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.version/ +""" +import asyncio +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import __version__, CONF_NAME +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Current Version" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Version sensor platform.""" + name = config.get(CONF_NAME) + + async_add_devices([VersionSensor(name)]) + + +class VersionSensor(Entity): + """Representation of a Home Assistant version sensor.""" + + def __init__(self, name): + """Initialize the Version sensor.""" + self._name = name + self._state = __version__ + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def state(self): + """Return the state of the sensor.""" + return self._state diff --git a/homeassistant/components/sensor/worldtidesinfo.py b/homeassistant/components/sensor/worldtidesinfo.py new file mode 100644 index 0000000000000..f23d244cf3ae7 --- /dev/null +++ b/homeassistant/components/sensor/worldtidesinfo.py @@ -0,0 +1,113 @@ +""" +This component provides HA sensor support for the worldtides.info API. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.worldtidesinfo/ +""" +import logging +import time +from datetime import timedelta + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, + CONF_NAME, STATE_UNKNOWN) +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'WorldTidesInfo' + +SCAN_INTERVAL = timedelta(seconds=3600) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the WorldTidesInfo sensor.""" + name = config.get(CONF_NAME) + + lat = config.get(CONF_LATITUDE, hass.config.latitude) + lon = config.get(CONF_LONGITUDE, hass.config.longitude) + key = config.get(CONF_API_KEY) + + if None in (lat, lon): + _LOGGER.error("Latitude or longitude not set in Home Assistant config") + + add_devices([WorldTidesInfoSensor(name, lat, lon, key)], True) + + +class WorldTidesInfoSensor(Entity): + """Representation of a WorldTidesInfo sensor.""" + + def __init__(self, name, lat, lon, key): + """Initialize the sensor.""" + self._name = name + self._lat = lat + self._lon = lon + self._key = key + self.data = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes of this device.""" + attr = {} + if "High" in str(self.data['extremes'][0]['type']): + attr['high_tide_time_utc'] = self.data['extremes'][0]['date'] + attr['high_tide_height'] = self.data['extremes'][0]['height'] + attr['low_tide_time_utc'] = self.data['extremes'][1]['date'] + attr['low_tide_height'] = self.data['extremes'][1]['height'] + elif "Low" in str(self.data['extremes'][0]['type']): + attr['high_tide_time_utc'] = self.data['extremes'][1]['date'] + attr['high_tide_height'] = self.data['extremes'][1]['height'] + attr['low_tide_time_utc'] = self.data['extremes'][0]['date'] + attr['low_tide_height'] = self.data['extremes'][0]['height'] + return attr + + @property + def state(self): + """Return the state of the device.""" + if self.data: + if "High" in str(self.data['extremes'][0]['type']): + tidetime = time.strftime('%I:%M %p', time.localtime( + self.data['extremes'][0]['dt'])) + return "High tide at %s" % (tidetime) + elif "Low" in str(self.data['extremes'][0]['type']): + tidetime = time.strftime('%I:%M %p', time.localtime( + self.data['extremes'][0]['dt'])) + return "Low tide at %s" % (tidetime) + else: + return STATE_UNKNOWN + else: + return STATE_UNKNOWN + + def update(self): + """Get the latest data from WorldTidesInfo API.""" + start = int(time.time()) + resource = 'https://www.worldtides.info/api?extremes&length=86400' \ + '&key=%s&lat=%s&lon=%s&start=%s' % (self._key, self._lat, + self._lon, start) + + try: + self.data = requests.get(resource, timeout=10).json() + _LOGGER.debug("Data = %s", self.data) + _LOGGER.info("Tide data queried with start time set to: %s", + (start)) + except ValueError as err: + _LOGGER.error("Check WorldTidesInfo %s", err.args) + self.data = None + raise diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 7315b6dc2d22b..57820917cab13 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -546,3 +546,28 @@ rflink: command: description: The command to be sent example: 'on' + +counter: + decrement: + description: Decrement a counter. + + fields: + entity_id: + description: Entity id of the counter to decrement. + example: 'counter.count0' + + increment: + description: Increment a counter. + + fields: + entity_id: + description: Entity id of the counter to increment. + example: 'counter.count0' + + reset: + description: Reset a counter. + + fields: + entity_id: + description: Entity id of the counter to reset. + example: 'counter.count0' diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index 6243de0b2d609..1f64f78e9c8af 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -66,7 +66,7 @@ def message_received(topic, payload, qos): yield from intent.async_handle( hass, DOMAIN, intent_type, slots, request['input']) except intent.IntentError: - _LOGGER.exception("Error while handling intent.") + _LOGGER.exception("Error while handling intent: %s.", intent_type) yield from hass.components.mqtt.async_subscribe( INTENT_TOPIC, message_received) diff --git a/homeassistant/components/switch/abode.py b/homeassistant/components/switch/abode.py new file mode 100644 index 0000000000000..bed0b9c0b60ec --- /dev/null +++ b/homeassistant/components/switch/abode.py @@ -0,0 +1,53 @@ +""" +This component provides HA switch support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.switch import SwitchDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode switch devices.""" + import abodepy.helpers.constants as CONST + + abode = hass.data[DATA_ABODE] + + device_types = [ + CONST.DEVICE_POWER_SWITCH_SENSOR, + CONST.DEVICE_POWER_SWITCH_METER] + + sensors = [] + for sensor in abode.get_devices(type_filter=device_types): + sensors.append(AbodeSwitch(abode, sensor)) + + add_devices(sensors) + + +class AbodeSwitch(AbodeDevice, SwitchDevice): + """Representation of an Abode switch.""" + + def __init__(self, controller, device): + """Initialize the Abode device.""" + AbodeDevice.__init__(self, controller, device) + + def turn_on(self, **kwargs): + """Turn on the device.""" + self._device.switch_on() + + def turn_off(self, **kwargs): + """Turn off the device.""" + self._device.switch_off() + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.is_on diff --git a/homeassistant/components/switch/digitalloggers.py b/homeassistant/components/switch/digitalloggers.py index 26493122184b4..0625a42f765c5 100755 --- a/homeassistant/components/switch/digitalloggers.py +++ b/homeassistant/components/switch/digitalloggers.py @@ -46,7 +46,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import dlipower host = config.get(CONF_HOST) - controllername = config.get(CONF_NAME) + controller_name = config.get(CONF_NAME) user = config.get(CONF_USERNAME) pswd = config.get(CONF_PASSWORD) tout = config.get(CONF_TIMEOUT) @@ -61,37 +61,42 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("Could not connect to DIN III Relay") return False - devices = [] + outlets = [] parent_device = DINRelayDevice(power_switch) - devices.extend( - DINRelay(controllername, device.outlet_number, parent_device) - for device in power_switch + outlets.extend( + DINRelay(controller_name, parent_device, outlet) + for outlet in power_switch[0:] ) - add_devices(devices, True) + add_devices(outlets) class DINRelay(SwitchDevice): """Representation of a individual DIN III relay port.""" - def __init__(self, name, outletnumber, parent_device): + def __init__(self, controller_name, parent_device, outlet): """Initialize the DIN III Relay switch.""" + self._controller_name = controller_name self._parent_device = parent_device - self.controllername = name - self.outletnumber = outletnumber - self._outletname = '' - self._is_on = False + self._outlet = outlet + + self._outlet_number = self._outlet.outlet_number + self._name = self._outlet.description + self._state = self._outlet.state == 'ON' @property def name(self): """Return the display name of this relay.""" - return self._outletname + return '{}_{}'.format( + self._controller_name, + self._name + ) @property def is_on(self): """Return true if relay is on.""" - return self._is_on + return self._state @property def should_poll(self): @@ -100,41 +105,36 @@ def should_poll(self): def turn_on(self, **kwargs): """Instruct the relay to turn on.""" - self._parent_device.turn_on(outlet=self.outletnumber) + self._outlet.on() def turn_off(self, **kwargs): """Instruct the relay to turn off.""" - self._parent_device.turn_off(outlet=self.outletnumber) + self._outlet.off() def update(self): """Trigger update for all switches on the parent device.""" self._parent_device.update() - self._is_on = ( - self._parent_device.statuslocal[self.outletnumber - 1][2] == 'ON' - ) - self._outletname = '{}_{}'.format( - self.controllername, - self._parent_device.statuslocal[self.outletnumber - 1][1] - ) + + outlet_status = self._parent_device.get_outlet_status( + self._outlet_number) + + self._name = outlet_status[1] + self._state = outlet_status[2] == 'ON' class DINRelayDevice(object): """Device representation for per device throttling.""" - def __init__(self, device): + def __init__(self, power_switch): """Initialize the DINRelay device.""" - self._device = device - self.statuslocal = None + self._power_switch = power_switch + self._statuslist = None - def turn_on(self, **kwargs): - """Instruct the relay to turn on.""" - self._device.on(**kwargs) - - def turn_off(self, **kwargs): - """Instruct the relay to turn off.""" - self._device.off(**kwargs) + def get_outlet_status(self, outlet_number): + """Get status of outlet from cached status list.""" + return self._statuslist[outlet_number - 1] @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Fetch new state data for this device.""" - self.statuslocal = self._device.statuslist() + self._statuslist = self._power_switch.statuslist() diff --git a/homeassistant/components/switch/dlink.py b/homeassistant/components/switch/dlink.py index b24693da616d5..f6ed6dac018c1 100644 --- a/homeassistant/components/switch/dlink.py +++ b/homeassistant/components/switch/dlink.py @@ -14,7 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN -REQUIREMENTS = ['pyW215==0.5.1'] +REQUIREMENTS = ['pyW215==0.6.0'] _LOGGER = logging.getLogger(__name__) @@ -23,9 +23,9 @@ DEFAULT_USERNAME = 'admin' CONF_USE_LEGACY_PROTOCOL = 'use_legacy_protocol' -ATTR_CURRENT_CONSUMPTION = 'Current Consumption' -ATTR_TOTAL_CONSUMPTION = 'Total Consumption' -ATTR_TEMPERATURE = 'Temperature' +ATTR_CURRENT_CONSUMPTION = 'power_consumption' +ATTR_TOTAL_CONSUMPTION = 'total_consumption' +ATTR_TEMPERATURE = 'temperature' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index 5613bcbb19e19..e8bd592cee80a 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -6,8 +6,9 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/switch.flux/ """ -from datetime import time +import datetime import logging + import voluptuous as vol from homeassistant.components.light import is_on, turn_on @@ -46,7 +47,7 @@ vol.Required(CONF_LIGHTS): cv.entity_ids, vol.Optional(CONF_NAME, default="Flux"): cv.string, vol.Optional(CONF_START_TIME): cv.time, - vol.Optional(CONF_STOP_TIME, default=time(22, 0)): cv.time, + vol.Optional(CONF_STOP_TIME, default=datetime.time(22, 0)): cv.time, vol.Optional(CONF_START_CT, default=4000): vol.All(vol.Coerce(int), vol.Range(min=1000, max=40000)), vol.Optional(CONF_SUNSET_CT, default=3000): @@ -171,12 +172,22 @@ def flux_update(self, now=None): """Update all the lights using flux.""" if now is None: now = dt_now() + sunset = get_astral_event_date(self.hass, 'sunset', now.date()) start_time = self.find_start_time(now) stop_time = now.replace( hour=self._stop_time.hour, minute=self._stop_time.minute, second=0) + if stop_time <= start_time: + # stop_time does not happen in the same day as start_time + if start_time < now: + # stop time is tomorrow + stop_time += datetime.timedelta(days=1) + elif now < start_time: + # stop_time was yesterday since the new start_time is not reached + stop_time -= datetime.timedelta(days=1) + if start_time < now < sunset: # Daytime time_state = 'day' @@ -192,15 +203,24 @@ def flux_update(self, now=None): else: # Nightime time_state = 'night' - if now < stop_time and now > start_time: - now_time = now + + if now < stop_time: + if stop_time < start_time and stop_time.day == sunset.day: + # we need to use yesterday's sunset time + sunset_time = sunset - datetime.timedelta(days=1) + else: + sunset_time = sunset + + # pylint: disable=no-member + night_length = int(stop_time.timestamp() - + sunset_time.timestamp()) + seconds_from_sunset = int(now.timestamp() - + sunset_time.timestamp()) + percentage_complete = seconds_from_sunset / night_length else: - now_time = stop_time + percentage_complete = 1 + temp_range = abs(self._sunset_colortemp - self._stop_colortemp) - night_length = int(stop_time.timestamp() - sunset.timestamp()) - seconds_from_sunset = int(now_time.timestamp() - - sunset.timestamp()) - percentage_complete = seconds_from_sunset / night_length temp_offset = temp_range * percentage_complete if self._sunset_colortemp > self._stop_colortemp: temp = self._sunset_colortemp - temp_offset diff --git a/homeassistant/components/switch/homematic.py b/homeassistant/components/switch/homematic.py index 566eff99828a9..487947598bbad 100644 --- a/homeassistant/components/switch/homematic.py +++ b/homeassistant/components/switch/homematic.py @@ -21,8 +21,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMSwitch(hass, conf) - new_device.link_homematic() + new_device = HMSwitch(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/switch/insteon_local.py b/homeassistant/components/switch/insteon_local.py index e6e34f6de27b4..94259b8bb8007 100644 --- a/homeassistant/components/switch/insteon_local.py +++ b/homeassistant/components/switch/insteon_local.py @@ -10,7 +10,6 @@ from datetime import timedelta from homeassistant.components.switch import SwitchDevice -from homeassistant.loader import get_component import homeassistant.util as util _CONFIGURING = {} @@ -51,7 +50,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): def request_configuration( device_id, insteonhub, model, hass, add_devices_callback): """Request configuration steps from the user.""" - configurator = get_component('configurator') + configurator = hass.components.configurator # We got an error if this method is called while we are configuring if device_id in _CONFIGURING: @@ -66,7 +65,7 @@ def insteon_switch_config_callback(data): add_devices_callback) _CONFIGURING[device_id] = configurator.request_config( - hass, 'Insteon Switch ' + model + ' addr: ' + device_id, + 'Insteon Switch ' + model + ' addr: ' + device_id, insteon_switch_config_callback, description=('Enter a name for ' + model + ' addr: ' + device_id), entity_picture='/static/images/config_insteon.png', @@ -79,7 +78,7 @@ def setup_switch(device_id, name, insteonhub, hass, add_devices_callback): """Set up the switch.""" if device_id in _CONFIGURING: request_id = _CONFIGURING.pop(device_id) - configurator = get_component('configurator') + configurator = hass.components.configurator configurator.request_done(request_id) _LOGGER.info("Device configuration done") diff --git a/homeassistant/components/switch/knx.py b/homeassistant/components/switch/knx.py index d07df08ed5c71..90b0423908659 100644 --- a/homeassistant/components/switch/knx.py +++ b/homeassistant/components/switch/knx.py @@ -1,14 +1,16 @@ """ -Support KNX switching actuators. +Support for KNX/IP switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.knx/ """ +import asyncio import voluptuous as vol -from homeassistant.components.knx import (KNXConfig, KNXGroupAddress) -from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice from homeassistant.const import CONF_NAME +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv CONF_ADDRESS = 'address' @@ -24,30 +26,85 @@ }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX switch platform.""" - add_devices([KNXSwitch(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up switch(es) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) -class KNXSwitch(KNXGroupAddress, SwitchDevice): - """Representation of a KNX switch device.""" + return True - def turn_on(self, **kwargs): - """Turn the switch on. - This sends a value 0 to the group address of the device - """ - self.group_write(1) - self._state = [1] - if not self.should_poll: - self.schedule_update_ha_state() +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up switches for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXSwitch(hass, device)) + add_devices(entities) - def turn_off(self, **kwargs): - """Turn the switch off. - This sends a value 1 to the group address of the device - """ - self.group_write(0) - self._state = [0] - if not self.should_poll: - self.schedule_update_ha_state() +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up switch for KNX platform configured within plattform.""" + import xknx + switch = xknx.devices.Switch( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address=config.get(CONF_ADDRESS), + group_address_state=config.get(CONF_STATE_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(switch) + add_devices([KNXSwitch(hass, switch)]) + + +class KNXSwitch(SwitchDevice): + """Representation of a KNX switch.""" + + def __init__(self, hass, device): + """Initialization of KNXSwitch.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name + + @property + def should_poll(self): + """No polling needed within KNX.""" + return False + + @property + def is_on(self): + """Return true if device is on.""" + return self.device.state + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the device on.""" + yield from self.device.set_on() + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the device off.""" + yield from self.device.set_off() diff --git a/homeassistant/components/switch/lutron_caseta.py b/homeassistant/components/switch/lutron_caseta.py index 585dc04331556..daaba68dc5e55 100644 --- a/homeassistant/components/switch/lutron_caseta.py +++ b/homeassistant/components/switch/lutron_caseta.py @@ -8,7 +8,7 @@ from homeassistant.components.lutron_caseta import ( LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice) -from homeassistant.components.switch import SwitchDevice +from homeassistant.components.switch import SwitchDevice, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -20,7 +20,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Lutron switch.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] - switch_devices = bridge.get_devices_by_type("WallSwitch") + switch_devices = bridge.get_devices_by_domain(DOMAIN) for switch_device in switch_devices: dev = LutronCasetaLight(switch_device, bridge) diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index 95f9a7793279d..308cce4de4680 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -24,17 +24,25 @@ DEPENDENCIES = ['mqtt'] +CONF_PAYLOAD_AVAILABLE = 'payload_available' +CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' + DEFAULT_NAME = 'MQTT Switch' DEFAULT_PAYLOAD_ON = 'ON' DEFAULT_PAYLOAD_OFF = 'OFF' DEFAULT_OPTIMISTIC = False +DEFAULT_PAYLOAD_AVAILABLE = 'ON' +DEFAULT_PAYLOAD_NOT_AVAILABLE = 'OFF' PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_AVAILABILITY_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_PAYLOAD_AVAILABLE, + default=DEFAULT_PAYLOAD_AVAILABLE): cv.string, + vol.Optional(CONF_PAYLOAD_NOT_AVAILABLE, + default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string, }) @@ -58,6 +66,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_PAYLOAD_ON), config.get(CONF_PAYLOAD_OFF), config.get(CONF_OPTIMISTIC), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), value_template, )]) @@ -67,7 +77,7 @@ class MqttSwitch(SwitchDevice): def __init__(self, name, state_topic, command_topic, availability_topic, qos, retain, payload_on, payload_off, optimistic, - value_template): + payload_available, payload_not_available, value_template): """Initialize the MQTT switch.""" self._state = False self._name = name @@ -81,6 +91,8 @@ def __init__(self, name, state_topic, command_topic, availability_topic, self._payload_off = payload_off self._optimistic = optimistic self._template = value_template + self._payload_available = payload_available + self._payload_not_available = payload_not_available @asyncio.coroutine def async_added_to_hass(self): @@ -104,9 +116,9 @@ def state_message_received(topic, payload, qos): @callback def availability_message_received(topic, payload, qos): """Handle new MQTT availability messages.""" - if payload == self._payload_on: + if payload == self._payload_available: self._available = True - elif payload == self._payload_off: + elif payload == self._payload_not_available: self._available = False self.hass.async_add_job(self.async_update_ha_state()) diff --git a/homeassistant/components/switch/mysensors.py b/homeassistant/components/switch/mysensors.py index 38f67ee3ee9f6..131ec58ae67ac 100644 --- a/homeassistant/components/switch/mysensors.py +++ b/homeassistant/components/switch/mysensors.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.mysensors/ """ -import logging import os import voluptuous as vol @@ -15,9 +14,6 @@ from homeassistant.config import load_yaml_config_file from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON -_LOGGER = logging.getLogger(__name__) -DEPENDENCIES = [] - ATTR_IR_CODE = 'V_IR_SEND' SERVICE_SEND_IR_CODE = 'mysensors_send_ir_code' @@ -29,82 +25,37 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the mysensors platform for switches.""" - # Only act if loaded via mysensors by discovery event. - # Otherwise gateway is not setup. - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - platform_devices = [] - - for gateway in gateways: - # Define the S_TYPES and V_TYPES that the platform should handle as - # states. Map them in a dict of lists. - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_DOOR: [set_req.V_ARMED], - pres.S_MOTION: [set_req.V_ARMED], - pres.S_SMOKE: [set_req.V_ARMED], - pres.S_LIGHT: [set_req.V_LIGHT], - pres.S_LOCK: [set_req.V_LOCK_STATUS], - pres.S_IR: [set_req.V_IR_SEND], - } - device_class_map = { - pres.S_DOOR: MySensorsSwitch, - pres.S_MOTION: MySensorsSwitch, - pres.S_SMOKE: MySensorsSwitch, - pres.S_LIGHT: MySensorsSwitch, - pres.S_LOCK: MySensorsSwitch, - pres.S_IR: MySensorsIRSwitch, - } - if float(gateway.protocol_version) >= 1.5: - map_sv_types.update({ - pres.S_BINARY: [set_req.V_STATUS, set_req.V_LIGHT], - pres.S_SPRINKLER: [set_req.V_STATUS], - pres.S_WATER_LEAK: [set_req.V_ARMED], - pres.S_SOUND: [set_req.V_ARMED], - pres.S_VIBRATION: [set_req.V_ARMED], - pres.S_MOISTURE: [set_req.V_ARMED], - }) - map_sv_types[pres.S_LIGHT].append(set_req.V_STATUS) - device_class_map.update({ - pres.S_BINARY: MySensorsSwitch, - pres.S_SPRINKLER: MySensorsSwitch, - pres.S_WATER_LEAK: MySensorsSwitch, - pres.S_SOUND: MySensorsSwitch, - pres.S_VIBRATION: MySensorsSwitch, - pres.S_MOISTURE: MySensorsSwitch, - }) - if float(gateway.protocol_version) >= 2.0: - map_sv_types.update({ - pres.S_WATER_QUALITY: [set_req.V_STATUS], - }) - device_class_map.update({ - pres.S_WATER_QUALITY: MySensorsSwitch, - }) - - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, device_class_map, add_devices)) - platform_devices.append(devices) + device_class_map = { + 'S_DOOR': MySensorsSwitch, + 'S_MOTION': MySensorsSwitch, + 'S_SMOKE': MySensorsSwitch, + 'S_LIGHT': MySensorsSwitch, + 'S_LOCK': MySensorsSwitch, + 'S_IR': MySensorsIRSwitch, + 'S_BINARY': MySensorsSwitch, + 'S_SPRINKLER': MySensorsSwitch, + 'S_WATER_LEAK': MySensorsSwitch, + 'S_SOUND': MySensorsSwitch, + 'S_VIBRATION': MySensorsSwitch, + 'S_MOISTURE': MySensorsSwitch, + 'S_WATER_QUALITY': MySensorsSwitch, + } + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, device_class_map, + add_devices=add_devices) def send_ir_code_service(service): """Set IR code as device state attribute.""" entity_ids = service.data.get(ATTR_ENTITY_ID) ir_code = service.data.get(ATTR_IR_CODE) + devices = mysensors.get_mysensors_devices(hass, DOMAIN) if entity_ids: - _devices = [device for gw_devs in platform_devices - for device in gw_devs.values() + _devices = [device for device in devices.values() if isinstance(device, MySensorsIRSwitch) and device.entity_id in entity_ids] else: - _devices = [device for gw_devs in platform_devices - for device in gw_devs.values() + _devices = [device for device in devices.values() if isinstance(device, MySensorsIRSwitch)] kwargs = {ATTR_IR_CODE: ir_code} @@ -120,7 +71,7 @@ def send_ir_code_service(service): schema=SEND_IR_CODE_SERVICE_SCHEMA) -class MySensorsSwitch(mysensors.MySensorsDeviceEntity, SwitchDevice): +class MySensorsSwitch(mysensors.MySensorsEntity, SwitchDevice): """Representation of the value of a MySensors Switch child node.""" @property @@ -131,9 +82,7 @@ def assumed_state(self): @property def is_on(self): """Return True if switch is on.""" - if self.value_type in self._values: - return self._values[self.value_type] == STATE_ON - return False + return self._values.get(self.value_type) == STATE_ON def turn_on(self, **kwargs): """Turn the switch on.""" @@ -159,24 +108,18 @@ class MySensorsIRSwitch(MySensorsSwitch): def __init__(self, *args): """Set up instance attributes.""" - MySensorsSwitch.__init__(self, *args) + super().__init__(*args) self._ir_code = None @property def is_on(self): """Return True if switch is on.""" set_req = self.gateway.const.SetReq - if set_req.V_LIGHT in self._values: - return self._values[set_req.V_LIGHT] == STATE_ON - return False + return self._values.get(set_req.V_LIGHT) == STATE_ON def turn_on(self, **kwargs): """Turn the IR switch on.""" set_req = self.gateway.const.SetReq - if set_req.V_LIGHT not in self._values: - _LOGGER.error('missing value_type: %s at node: %s, child: %s', - set_req.V_LIGHT.name, self.node_id, self.child_id) - return if ATTR_IR_CODE in kwargs: self._ir_code = kwargs[ATTR_IR_CODE] self.gateway.set_child_value( @@ -194,10 +137,6 @@ def turn_on(self, **kwargs): def turn_off(self, **kwargs): """Turn the IR switch off.""" set_req = self.gateway.const.SetReq - if set_req.V_LIGHT not in self._values: - _LOGGER.error('missing value_type: %s at node: %s, child: %s', - set_req.V_LIGHT.name, self.node_id, self.child_id) - return self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_LIGHT, 0) if self.gateway.optimistic: @@ -207,6 +146,5 @@ def turn_off(self, **kwargs): def update(self): """Update the controller with the latest value from a sensor.""" - MySensorsSwitch.update(self) - if self.value_type in self._values: - self._ir_code = self._values[self.value_type] + super().update() + self._ir_code = self._values.get(self.value_type) diff --git a/homeassistant/components/switch/pilight.py b/homeassistant/components/switch/pilight.py index b56367e80bebb..201aee0f58c84 100644 --- a/homeassistant/components/switch/pilight.py +++ b/homeassistant/components/switch/pilight.py @@ -4,6 +4,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.pilight/ """ +import asyncio import logging import voluptuous as vol @@ -12,7 +13,8 @@ import homeassistant.components.pilight as pilight from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_NAME, CONF_ID, CONF_SWITCHES, CONF_STATE, - CONF_PROTOCOL) + CONF_PROTOCOL, STATE_ON) +from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) @@ -120,6 +122,13 @@ def __init__(self, hass, name, code_on, code_off, code_on_receive, if any(self._code_on_receive) or any(self._code_off_receive): hass.bus.listen(pilight.EVENT, self._handle_code) + @asyncio.coroutine + def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + state = yield from async_get_last_state(self._hass, self.entity_id) + if state: + self._state = state.state == STATE_ON + @property def name(self): """Get the name of the switch.""" @@ -130,6 +139,11 @@ def should_poll(self): """No polling needed, state set when correct code is received.""" return False + @property + def assumed_state(self): + """Return True if unable to access real state of the entity.""" + return True + @property def is_on(self): """Return true if switch is on.""" diff --git a/homeassistant/components/switch/rainmachine.py b/homeassistant/components/switch/rainmachine.py index 648fad21a8bc9..94ac98c173721 100644 --- a/homeassistant/components/switch/rainmachine.py +++ b/homeassistant/components/switch/rainmachine.py @@ -8,20 +8,21 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.switch import SwitchDevice -from homeassistant.const import (ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, - CONF_EMAIL, CONF_IP_ADDRESS, CONF_PASSWORD, - CONF_PLATFORM, CONF_SCAN_INTERVAL) +from homeassistant.const import ( + ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, CONF_EMAIL, CONF_IP_ADDRESS, + CONF_PASSWORD, CONF_PLATFORM, CONF_PORT, CONF_SCAN_INTERVAL, CONF_SSL) from homeassistant.util import Throttle _LOGGER = getLogger(__name__) -REQUIREMENTS = ['regenmaschine==0.3.2'] +REQUIREMENTS = ['regenmaschine==0.4.1'] ATTR_CYCLES = 'cycles' ATTR_TOTAL_DURATION = 'total_duration' -CONF_HIDE_DISABLED_ENTITIES = 'hide_disabled_entities' CONF_ZONE_RUN_TIME = 'zone_run_time' +DEFAULT_PORT = 8080 +DEFAULT_SSL = True DEFAULT_ZONE_RUN_SECONDS = 60 * 10 MIN_SCAN_TIME_LOCAL = timedelta(seconds=1) @@ -42,10 +43,12 @@ vol.Email(), # pylint: disable=no-value-for-parameter vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): + cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): + cv.boolean, vol.Optional(CONF_ZONE_RUN_TIME, default=DEFAULT_ZONE_RUN_SECONDS): - cv.positive_int, - vol.Optional(CONF_HIDE_DISABLED_ENTITIES, default=True): - cv.boolean + cv.positive_int }), extra=vol.ALLOW_EXTRA) @@ -64,28 +67,34 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): password = config.get(CONF_PASSWORD) _LOGGER.debug('Password: %s', password) - hide_disabled_entities = config.get(CONF_HIDE_DISABLED_ENTITIES) - _LOGGER.debug('Show disabled entities: %s', hide_disabled_entities) - zone_run_time = config.get(CONF_ZONE_RUN_TIME) _LOGGER.debug('Zone run time: %s', zone_run_time) try: if ip_address: - _LOGGER.debug('Configuring local API...') - auth = rm.Authenticator.create_local(ip_address, password) + port = config.get(CONF_PORT) + _LOGGER.debug('Port: %s', port) + + ssl = config.get(CONF_SSL) + _LOGGER.debug('SSL: %s', ssl) + + _LOGGER.debug('Configuring local API') + auth = rm.Authenticator.create_local( + ip_address, password, port=port, https=ssl) elif email_address: - _LOGGER.debug('Configuring remote API...') + _LOGGER.debug('Configuring remote API') auth = rm.Authenticator.create_remote(email_address, password) - _LOGGER.debug('Instantiating RainMachine client...') + _LOGGER.debug('Querying against: %s', auth.url) + + _LOGGER.debug('Instantiating RainMachine client') client = rm.Client(auth) rainmachine_device_name = client.provision.device_name().get('name') entities = [] for program in client.programs.all().get('programs'): - if hide_disabled_entities and program.get('active') is False: + if not program.get('active'): continue _LOGGER.debug('Adding program: %s', program) @@ -94,7 +103,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): client, program, device_name=rainmachine_device_name)) for zone in client.zones.all().get('zones'): - if hide_disabled_entities and zone.get('active') is False: + if not zone.get('active'): continue _LOGGER.debug('Adding zone: %s', zone) diff --git a/homeassistant/components/switch/rfxtrx.py b/homeassistant/components/switch/rfxtrx.py index 36044f5f168f8..1361d22de1828 100644 --- a/homeassistant/components/switch/rfxtrx.py +++ b/homeassistant/components/switch/rfxtrx.py @@ -21,7 +21,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): import RFXtrx as rfxtrxmod # Add switch from config file - switches = rfxtrx.get_devices_from_config(config, RfxtrxSwitch, hass) + switches = rfxtrx.get_devices_from_config(config, RfxtrxSwitch) add_devices_callback(switches) def switch_update(event): @@ -31,7 +31,7 @@ def switch_update(event): event.device.known_to_be_rollershutter: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxSwitch, hass) + new_device = rfxtrx.get_new_device(event, config, RfxtrxSwitch) if new_device: add_devices_callback([new_device]) diff --git a/homeassistant/components/switch/xiaomi.py b/homeassistant/components/switch/xiaomi.py index 3e4ea4f6d72b6..767043a8bc926 100644 --- a/homeassistant/components/switch/xiaomi.py +++ b/homeassistant/components/switch/xiaomi.py @@ -6,9 +6,13 @@ _LOGGER = logging.getLogger(__name__) -ATTR_LOAD_POWER = 'Load power' # Load power in watts (W) -ATTR_POWER_CONSUMED = 'Power consumed' -ATTR_IN_USE = 'In use' +# Load power in watts (W) +ATTR_LOAD_POWER = 'load_power' + +# Total (lifetime) power consumption in watts +ATTR_POWER_CONSUMED = 'power_consumed' +ATTR_IN_USE = 'in_use' + LOAD_POWER = 'load_power' POWER_CONSUMED = 'power_consumed' IN_USE = 'inuse' diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 38669ff4ee66c..de9c0f4ede3d4 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -24,7 +24,7 @@ from homeassistant.exceptions import TemplateError from homeassistant.setup import async_prepare_setup_platform -REQUIREMENTS = ['python-telegram-bot==7.0.1'] +REQUIREMENTS = ['python-telegram-bot==8.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tesla.py b/homeassistant/components/tesla.py new file mode 100644 index 0000000000000..08006310dc7d3 --- /dev/null +++ b/homeassistant/components/tesla.py @@ -0,0 +1,123 @@ +""" +Support for Tesla cars. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/tesla/ +""" +from collections import defaultdict +import logging + +from urllib.error import HTTPError +import voluptuous as vol + +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL) +from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify + +REQUIREMENTS = ['teslajsonpy==0.0.11'] + +DOMAIN = 'tesla' + +_LOGGER = logging.getLogger(__name__) + +TESLA_ID_FORMAT = '{}_{}' +TESLA_ID_LIST_SCHEMA = vol.Schema([int]) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=300): + vol.All(cv.positive_int, vol.Clamp(min=300)), + }), +}, extra=vol.ALLOW_EXTRA) + +NOTIFICATION_ID = 'tesla_integration_notification' +NOTIFICATION_TITLE = 'Tesla integration setup' + +TESLA_COMPONENTS = [ + 'sensor', 'lock', 'climate', 'binary_sensor', 'device_tracker' +] + + +def setup(hass, base_config): + """Set up of Tesla platform.""" + from teslajsonpy.controller import Controller as teslaApi + + config = base_config.get(DOMAIN) + + email = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + update_interval = config.get(CONF_SCAN_INTERVAL) + if hass.data.get(DOMAIN) is None: + try: + hass.data[DOMAIN] = { + 'controller': teslaApi(email, password, update_interval), + 'devices': defaultdict(list) + } + _LOGGER.debug("Connected to the Tesla API.") + except HTTPError as ex: + if ex.code == 401: + hass.components.persistent_notification.create( + "Error:
Please check username and password." + "You will need to restart Home Assistant after fixing.", + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + else: + hass.components.persistent_notification.create( + "Error:
Can't communicate with Tesla API.
" + "Error code: {} Reason: {}" + "You will need to restart Home Assistant after fixing." + "".format(ex.code, ex.reason), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + _LOGGER.error("Unable to communicate with Tesla API: %s", + ex.reason) + + return False + + all_devices = hass.data[DOMAIN]['controller'].list_vehicles() + + if not all_devices: + return False + + for device in all_devices: + hass.data[DOMAIN]['devices'][device.hass_type].append(device) + + for component in TESLA_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, base_config) + + return True + + +class TeslaDevice(Entity): + """Representation of a Tesla device.""" + + def __init__(self, tesla_device, controller): + """Initialisation of the Tesla device.""" + self.tesla_device = tesla_device + self.controller = controller + self._name = self.tesla_device.name + self.tesla_id = slugify(self.tesla_device.uniq_name) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """Get polling requirement from tesla device.""" + return self.tesla_device.should_poll + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = {} + + if self.tesla_device.has_battery(): + attr[ATTR_BATTERY_LEVEL] = self.tesla_device.battery_level() + return attr diff --git a/homeassistant/components/tradfri.py b/homeassistant/components/tradfri.py index cd83f81afd117..34422819743c3 100644 --- a/homeassistant/components/tradfri.py +++ b/homeassistant/components/tradfri.py @@ -14,15 +14,15 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery from homeassistant.const import CONF_HOST, CONF_API_KEY -from homeassistant.loader import get_component from homeassistant.components.discovery import SERVICE_IKEA_TRADFRI -REQUIREMENTS = ['pytradfri==1.1'] +REQUIREMENTS = ['pytradfri==2.2'] DOMAIN = 'tradfri' CONFIG_FILE = 'tradfri.conf' KEY_CONFIG = 'tradfri_configuring' KEY_GATEWAY = 'tradfri_gateway' +KEY_API = 'tradfri_api' KEY_TRADFRI_GROUPS = 'tradfri_allow_tradfri_groups' CONF_ALLOW_TRADFRI_GROUPS = 'allow_tradfri_groups' DEFAULT_ALLOW_TRADFRI_GROUPS = True @@ -41,7 +41,7 @@ def request_configuration(hass, config, host): """Request configuration steps from the user.""" - configurator = get_component('configurator') + configurator = hass.components.configurator hass.data.setdefault(KEY_CONFIG, {}) instance = hass.data[KEY_CONFIG].get(host) @@ -70,7 +70,7 @@ def success(): hass.async_add_job(success) instance = configurator.request_config( - hass, "IKEA Trådfri", configuration_callback, + "IKEA Trådfri", configuration_callback, description='Please enter the security code written at the bottom of ' 'your IKEA Trådfri Gateway.', submit_caption="Confirm", @@ -110,17 +110,21 @@ def gateway_discovered(service, info): @asyncio.coroutine def _setup_gateway(hass, hass_config, host, key, allow_tradfri_groups): """Create a gateway.""" - from pytradfri import cli_api_factory, Gateway, RequestError, retry_timeout + from pytradfri import Gateway, RequestError + from pytradfri.api.libcoap_api import api_factory try: - api = retry_timeout(cli_api_factory(host, key)) + api = api_factory(host, key) except RequestError: return False - gateway = Gateway(api) - gateway_id = gateway.get_gateway_info().id + gateway = Gateway() + # pylint: disable=no-member + gateway_id = api(gateway.get_gateway_info()).id + hass.data.setdefault(KEY_API, {}) hass.data.setdefault(KEY_GATEWAY, {}) gateways = hass.data[KEY_GATEWAY] + hass.data[KEY_API][gateway_id] = api hass.data.setdefault(KEY_TRADFRI_GROUPS, {}) tradfri_groups = hass.data[KEY_TRADFRI_GROUPS] diff --git a/homeassistant/components/usps.py b/homeassistant/components/usps.py new file mode 100644 index 0000000000000..fdafbbc35877c --- /dev/null +++ b/homeassistant/components/usps.py @@ -0,0 +1,85 @@ +""" +Support for USPS packages and mail. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/usps/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import ( + CONF_NAME, CONF_USERNAME, CONF_PASSWORD) +from homeassistant.helpers import (config_validation as cv, discovery) +from homeassistant.util import Throttle +from homeassistant.util.dt import now + +REQUIREMENTS = ['myusps==1.1.3'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'usps' +DATA_USPS = 'data_usps' +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) +COOKIE = 'usps_cookies.pickle' + +USPS_TYPE = ['sensor', 'camera'] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NAME, default=DOMAIN): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Use config values to set up a function enabling status retrieval.""" + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + name = conf.get(CONF_NAME) + + import myusps + try: + cookie = hass.config.path(COOKIE) + session = myusps.get_session(username, password, cookie_path=cookie) + except myusps.USPSError: + _LOGGER.exception('Could not connect to My USPS') + return False + + hass.data[DATA_USPS] = USPSData(session, name) + + for component in USPS_TYPE: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + + +class USPSData(object): + """Stores the data retrieved from USPS. + + For each entity to use, acts as the single point responsible for fetching + updates from the server. + """ + + def __init__(self, session, name): + """Initialize the data oject.""" + self.session = session + self.name = name + self.packages = [] + self.mail = [] + self.attribution = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, **kwargs): + """Fetch the latest info from USPS.""" + import myusps + self.packages = myusps.get_packages(self.session) + self.mail = myusps.get_mail(self.session, now().date()) + self.attribution = myusps.ATTRIBUTION + _LOGGER.debug("Mail, request date: %s, list: %s", + now().date(), self.mail) + _LOGGER.debug("Package list: %s", self.packages) diff --git a/homeassistant/components/vacuum/xiaomi.py b/homeassistant/components/vacuum/xiaomi.py index 5e5081a2aa8e1..95d7478aa9fc5 100644 --- a/homeassistant/components/vacuum/xiaomi.py +++ b/homeassistant/components/vacuum/xiaomi.py @@ -21,7 +21,7 @@ ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-mirobo==0.1.2'] +REQUIREMENTS = ['python-mirobo==0.1.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 5903bed1fc748..9c8366e7f7efc 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -73,14 +73,7 @@ def setup(hass, config): interval = config[DOMAIN].get(CONF_UPDATE_INTERVAL) - class state: # pylint:disable=invalid-name - """Namespace to hold state for each vehicle.""" - - entities = {} - vehicles = {} - names = config[DOMAIN].get(CONF_NAME) - - hass.data[DATA_KEY] = state + state = hass.data[DATA_KEY] = VolvoData(config) def discover_vehicle(vehicle): """Load relevant platforms.""" @@ -120,6 +113,31 @@ def update(now): return update(utcnow()) +class VolvoData: + """Hold component state.""" + + def __init__(self, config): + """Initialize the component state.""" + self.entities = {} + self.vehicles = {} + self.names = config[DOMAIN].get(CONF_NAME) + + def vehicle_name(self, vehicle): + """Provide a friendly name for a vehicle.""" + if (vehicle.registration_number and + vehicle.registration_number.lower()) in self.names: + return self.names[vehicle.registration_number.lower()] + elif (vehicle.vin and + vehicle.vin.lower() in self.names): + return self.names[vehicle.vin.lower()] + elif vehicle.registration_number: + return vehicle.registration_number + elif vehicle.vin: + return vehicle.vin + else: + return '' + + class VolvoEntity(Entity): """Base class for all VOC entities.""" @@ -139,17 +157,14 @@ def vehicle(self): """Return vehicle.""" return self._state.vehicles[self._vin] - @property - def _vehicle_name(self): - return (self._state.names.get(self._vin.lower()) or - self._state.names.get( - self.vehicle.registration_number.lower()) or - self.vehicle.registration_number) - @property def _entity_name(self): return RESOURCES[self._attribute][1] + @property + def _vehicle_name(self): + return self._state.vehicle_name(self.vehicle) + @property def name(self): """Return full name of the entity.""" diff --git a/homeassistant/components/weather/buienradar.py b/homeassistant/components/weather/buienradar.py index bca50182a16e3..f37914b3b0f74 100755 --- a/homeassistant/components/weather/buienradar.py +++ b/homeassistant/components/weather/buienradar.py @@ -7,7 +7,7 @@ import logging import asyncio from homeassistant.components.weather import ( - WeatherEntity, PLATFORM_SCHEMA) + WeatherEntity, PLATFORM_SCHEMA, ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME) from homeassistant.const import \ CONF_NAME, TEMP_CELSIUS, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.helpers import config_validation as cv @@ -16,14 +16,37 @@ BrData) import voluptuous as vol -REQUIREMENTS = ['buienradar==0.8'] +REQUIREMENTS = ['buienradar==0.9'] _LOGGER = logging.getLogger(__name__) +DATA_CONDITION = 'buienradar_condition' + DEFAULT_TIMEFRAME = 60 CONF_FORECAST = 'forecast' +ATTR_FORECAST_CONDITION = 'condition' +ATTR_FORECAST_TEMP_LOW = 'templow' + + +CONDITION_CLASSES = { + 'cloudy': ['c', 'p'], + 'fog': ['d', 'n'], + 'hail': [], + 'lightning': ['g'], + 'lightning-rainy': ['s'], + 'partlycloudy': ['b', 'j', 'o', 'r'], + 'pouring': ['l', 'q'], + 'rainy': ['f', 'h', 'k', 'm'], + 'snowy': ['u', 'i', 'v', 't'], + 'snowy-rainy': ['w'], + 'sunny': ['a'], + 'windy': [], + 'windy-variant': [], + 'exceptional': [], +} + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_LATITUDE): cv.latitude, @@ -50,8 +73,16 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): # create weather device: _LOGGER.debug("Initializing buienradar weather: coordinates %s", coordinates) - async_add_devices([BrWeather(data, config.get(CONF_FORECAST, True), - config.get(CONF_NAME, None))]) + + # create condition helper + if DATA_CONDITION not in hass.data: + cond_keys = [str(chr(x)) for x in range(97, 123)] + hass.data[DATA_CONDITION] = dict.fromkeys(cond_keys) + for cond, condlst in CONDITION_CLASSES.items(): + for condi in condlst: + hass.data[DATA_CONDITION][condi] = cond + + async_add_devices([BrWeather(data, config)]) # schedule the first update in 1 minute from now: yield from data.schedule_update(1) @@ -60,10 +91,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class BrWeather(WeatherEntity): """Representation of a weather condition.""" - def __init__(self, data, forecast, stationname=None): + def __init__(self, data, config): """Initialise the platform with a data instance and station name.""" - self._stationname = stationname - self._forecast = forecast + self._stationname = config.get(CONF_NAME, None) + self._forecast = config.get(CONF_FORECAST) self._data = data @property @@ -79,17 +110,32 @@ def name(self): @property def condition(self): - """Return the name of the sensor.""" - return self._data.condition + """Return the current condition.""" + from buienradar.buienradar import (CONDCODE) + if self._data and self._data.condition: + ccode = self._data.condition.get(CONDCODE) + if ccode: + conditions = self.hass.data.get(DATA_CONDITION) + if conditions: + return conditions.get(ccode) + + @property + def entity_picture(self): + """Return the entity picture to use in the frontend, if any.""" + from buienradar.buienradar import (IMAGE) + + if self._data and self._data.condition: + return self._data.condition.get(IMAGE, None) + return None @property def temperature(self): - """Return the name of the sensor.""" + """Return the current temperature.""" return self._data.temperature @property def pressure(self): - """Return the name of the sensor.""" + """Return the current pressure.""" return self._data.pressure @property @@ -97,14 +143,19 @@ def humidity(self): """Return the name of the sensor.""" return self._data.humidity + @property + def visibility(self): + """Return the current visibility.""" + return self._data.visibility + @property def wind_speed(self): - """Return the name of the sensor.""" + """Return the current windspeed.""" return self._data.wind_speed @property def wind_bearing(self): - """Return the name of the sensor.""" + """Return the current wind bearing (degrees).""" return self._data.wind_bearing @property @@ -114,6 +165,25 @@ def temperature_unit(self): @property def forecast(self): - """Return the forecast.""" + """Return the forecast array.""" + from buienradar.buienradar import (CONDITION, CONDCODE, DATETIME, + MIN_TEMP, MAX_TEMP) + if self._forecast: - return self._data.forecast + fcdata_out = [] + cond = self.hass.data[DATA_CONDITION] + if self._data.forecast: + for data_in in self._data.forecast: + # remap keys from external library to + # keys understood by the weather component: + data_out = {} + condcode = data_in.get(CONDITION, []).get(CONDCODE) + + data_out[ATTR_FORECAST_TIME] = data_in.get(DATETIME) + data_out[ATTR_FORECAST_CONDITION] = cond[condcode] + data_out[ATTR_FORECAST_TEMP_LOW] = data_in.get(MIN_TEMP) + data_out[ATTR_FORECAST_TEMP] = data_in.get(MAX_TEMP) + + fcdata_out.append(data_out) + + return fcdata_out diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py index 3d7226e3c8b7f..0592ad4c12470 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -14,7 +14,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP -REQUIREMENTS = ['pywemo==0.4.19'] +REQUIREMENTS = ['pywemo==0.4.20'] DOMAIN = 'wemo' diff --git a/homeassistant/components/wink.py b/homeassistant/components/wink.py index 8d40f5dad486b..23eb90daa8915 100644 --- a/homeassistant/components/wink.py +++ b/homeassistant/components/wink.py @@ -13,7 +13,6 @@ import voluptuous as vol import requests -from homeassistant.loader import get_component from homeassistant.core import callback from homeassistant.components.http import HomeAssistantView from homeassistant.helpers import discovery @@ -103,7 +102,7 @@ def _read_config_file(file_path): def _request_app_setup(hass, config): """Assist user with configuring the Wink dev application.""" hass.data[DOMAIN]['configurator'] = True - configurator = get_component('configurator') + configurator = hass.components.configurator # pylint: disable=unused-argument def wink_configuration_callback(callback_data): @@ -138,7 +137,7 @@ def wink_configuration_callback(callback_data): """.format(start_url) hass.data[DOMAIN]['configuring'][DOMAIN] = configurator.request_config( - hass, DOMAIN, wink_configuration_callback, + DOMAIN, wink_configuration_callback, description=description, submit_caption="submit", description_image="/static/images/config_wink.png", fields=[{'id': 'client_id', 'name': 'Client ID', 'type': 'string'}, @@ -151,7 +150,7 @@ def wink_configuration_callback(callback_data): def _request_oauth_completion(hass, config): """Request user complete Wink OAuth2 flow.""" hass.data[DOMAIN]['configurator'] = True - configurator = get_component('configurator') + configurator = hass.components.configurator if DOMAIN in hass.data[DOMAIN]['configuring']: configurator.notify_errors( hass.data[DOMAIN]['configuring'][DOMAIN], @@ -168,7 +167,7 @@ def wink_configuration_callback(callback_data): description = "Please authorize Wink by visiting {}".format(start_url) hass.data[DOMAIN]['configuring'][DOMAIN] = configurator.request_config( - hass, DOMAIN, wink_configuration_callback, + DOMAIN, wink_configuration_callback, description=description ) @@ -248,7 +247,7 @@ def _get_wink_token_from_web(): if DOMAIN in hass.data[DOMAIN]['configuring']: _configurator = hass.data[DOMAIN]['configuring'] - get_component('configurator').request_done(_configurator.pop( + hass.components.configurator.request_done(_configurator.pop( DOMAIN)) # Using oauth diff --git a/homeassistant/components/xiaomi.py b/homeassistant/components/xiaomi.py index 377446a66c8f4..1d14a76d25170 100644 --- a/homeassistant/components/xiaomi.py +++ b/homeassistant/components/xiaomi.py @@ -9,8 +9,7 @@ REQUIREMENTS = ['https://github.com/Danielhiversen/PyXiaomiGateway/archive/' - 'aa9325fe6fdd62a8ef8c9ca1dce31d3292f484bb.zip#' - 'PyXiaomiGateway==0.2.0'] + '0.3.2.zip#PyXiaomiGateway==0.3.2'] ATTR_GW_MAC = 'gw_mac' ATTR_RINGTONE_ID = 'ringtone_id' @@ -67,8 +66,8 @@ def setup(hass, config): interface) _LOGGER.debug("Expecting %s gateways", len(gateways)) - for _ in range(discovery_retry): - _LOGGER.info('Discovering Xiaomi Gateways (Try %s)', _ + 1) + for k in range(discovery_retry): + _LOGGER.info('Discovering Xiaomi Gateways (Try %s)', k + 1) hass.data[PY_XIAOMI_GATEWAY].discover_gateways() if len(hass.data[PY_XIAOMI_GATEWAY].gateways) >= len(gateways): break @@ -153,8 +152,8 @@ def __init__(self, device, name, xiaomi_hub): self._name = '{}_{}'.format(name, self._sid) self._write_to_hub = xiaomi_hub.write_to_hub self._get_from_hub = xiaomi_hub.get_from_hub - xiaomi_hub.callbacks[self._sid].append(self.push_data) self._device_state_attributes = {} + xiaomi_hub.callbacks[self._sid].append(self.push_data) self.parse_data(device['data']) self.parse_voltage(device['data']) @@ -165,7 +164,7 @@ def name(self): @property def should_poll(self): - """Poll update device status.""" + """No polling needed.""" return False @property diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index b72d9eb0cffa3..a238d01d52004 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -10,6 +10,7 @@ ATTR_OBJECT_ID = "object_id" ATTR_NAME = "name" ATTR_SCENE_ID = "scene_id" +ATTR_SCENE_DATA = "scene_data" ATTR_BASIC_LEVEL = "basic_level" ATTR_CONFIG_PARAMETER = "parameter" ATTR_CONFIG_SIZE = "size" diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index 3a810d00d2d4e..44a30cdc52997 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -7,8 +7,9 @@ from homeassistant.util import slugify from .const import ( - ATTR_NODE_ID, COMMAND_CLASS_WAKE_UP, ATTR_SCENE_ID, ATTR_BASIC_LEVEL, - EVENT_NODE_EVENT, EVENT_SCENE_ACTIVATED, DOMAIN) + ATTR_NODE_ID, COMMAND_CLASS_WAKE_UP, ATTR_SCENE_ID, ATTR_SCENE_DATA, + ATTR_BASIC_LEVEL, EVENT_NODE_EVENT, EVENT_SCENE_ACTIVATED, DOMAIN, + COMMAND_CLASS_CENTRAL_SCENE) from .util import node_name _LOGGER = logging.getLogger(__name__) @@ -107,13 +108,19 @@ def __init__(self, node, network, new_entity_ids): dispatcher.connect( self.network_scene_activated, ZWaveNetwork.SIGNAL_SCENE_EVENT) - def network_node_changed(self, node=None, args=None): + 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: return if args is not None and 'nodeId' in args and \ args['nodeId'] != self.node_id: return + + # Process central scene activation + if (value is not None and + value.command_class == COMMAND_CLASS_CENTRAL_SCENE): + self.central_scene_activated(value.index, value.data) + self.node_changed() def get_node_statistics(self): @@ -177,6 +184,18 @@ def scene_activated(self, scene_id): ATTR_SCENE_ID: scene_id }) + def central_scene_activated(self, scene_id, scene_data): + """Handle an activated central scene for this node.""" + if self.hass is None: + return + + self.hass.bus.fire(EVENT_SCENE_ACTIVATED, { + ATTR_ENTITY_ID: self.entity_id, + ATTR_NODE_ID: self.node_id, + ATTR_SCENE_ID: scene_id, + ATTR_SCENE_DATA: scene_data + }) + @property def state(self): """Return the state.""" diff --git a/homeassistant/config.py b/homeassistant/config.py index a4b7bce5dc0cc..ee48ece67ab92 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -57,6 +57,7 @@ CONF_UNIT_SYSTEM_IMPERIAL)), (CONF_TIME_ZONE, 'UTC', 'time_zone', 'Pick yours from here: http://en.wiki' 'pedia.org/wiki/List_of_tz_database_time_zones'), + (CONF_CUSTOMIZE, '!include customize.yaml', None, 'Customization file'), ) # type: Tuple[Tuple[str, Any, Any, str], ...] DEFAULT_CONFIG = """ # Show links to resources in log and frontend @@ -108,6 +109,7 @@ group: !include groups.yaml automation: !include automations.yaml +script: !include scripts.yaml """ @@ -173,11 +175,17 @@ def create_default_config(config_dir, detect_location=True): CONFIG_PATH as GROUP_CONFIG_PATH) from homeassistant.components.config.automation import ( CONFIG_PATH as AUTOMATION_CONFIG_PATH) + from homeassistant.components.config.script import ( + CONFIG_PATH as SCRIPT_CONFIG_PATH) + from homeassistant.components.config.customize import ( + CONFIG_PATH as CUSTOMIZE_CONFIG_PATH) config_path = os.path.join(config_dir, YAML_CONFIG_FILE) version_path = os.path.join(config_dir, VERSION_FILE) group_yaml_path = os.path.join(config_dir, GROUP_CONFIG_PATH) automation_yaml_path = os.path.join(config_dir, AUTOMATION_CONFIG_PATH) + script_yaml_path = os.path.join(config_dir, SCRIPT_CONFIG_PATH) + customize_yaml_path = os.path.join(config_dir, CUSTOMIZE_CONFIG_PATH) info = {attr: default for attr, default, _, _ in DEFAULT_CORE_CONFIG} @@ -216,12 +224,18 @@ def create_default_config(config_dir, detect_location=True): with open(version_path, 'wt') as version_file: version_file.write(__version__) - with open(group_yaml_path, 'w'): + with open(group_yaml_path, 'wt'): pass with open(automation_yaml_path, 'wt') as fil: fil.write('[]') + with open(script_yaml_path, 'wt'): + pass + + with open(customize_yaml_path, 'wt'): + pass + return config_path except IOError: diff --git a/homeassistant/const.py b/homeassistant/const.py index ab94bd3e42057..e31a04aa2910f 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 = 51 -PATCH_VERSION = '2' +MINOR_VERSION = 53 +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) @@ -101,6 +101,7 @@ CONF_EXCLUDE = 'exclude' CONF_FILE_PATH = 'file_path' CONF_FILENAME = 'filename' +CONF_FOR = 'for' CONF_FRIENDLY_NAME = 'friendly_name' CONF_HEADERS = 'headers' CONF_HOST = 'host' @@ -199,7 +200,10 @@ STATE_ALARM_DISARMED = 'disarmed' STATE_ALARM_ARMED_HOME = 'armed_home' STATE_ALARM_ARMED_AWAY = 'armed_away' +STATE_ALARM_ARMED_NIGHT = 'armed_night' STATE_ALARM_PENDING = 'pending' +STATE_ALARM_ARMING = 'arming' +STATE_ALARM_DISARMING = 'disarming' STATE_ALARM_TRIGGERED = 'triggered' STATE_LOCKED = 'locked' STATE_UNLOCKED = 'unlocked' @@ -347,6 +351,7 @@ SERVICE_ALARM_DISARM = 'alarm_disarm' SERVICE_ALARM_ARM_HOME = 'alarm_arm_home' SERVICE_ALARM_ARM_AWAY = 'alarm_arm_away' +SERVICE_ALARM_ARM_NIGHT = 'alarm_arm_night' SERVICE_ALARM_TRIGGER = 'alarm_trigger' SERVICE_LOCK = 'lock' diff --git a/homeassistant/core.py b/homeassistant/core.py index 496bb018fbdd6..187dfcf1b8368 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1079,6 +1079,8 @@ def path(self, *path): def is_allowed_path(self, path: str) -> bool: """Check if the path is valid for access from outside.""" + assert path is not None + parent = pathlib.Path(path).parent try: parent = parent.resolve() # pylint: disable=no-member diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index a8b18351021c2..29e2a6260fd40 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -90,8 +90,15 @@ def async_aiohttp_proxy_web(hass, request, web_coro, buffer_size=102400, # Something went wrong with the connection raise HTTPBadGateway() from err - yield from async_aiohttp_proxy_stream(hass, request, req.content, - req.headers.get(CONTENT_TYPE)) + try: + yield from async_aiohttp_proxy_stream( + hass, + request, + req.content, + req.headers.get(CONTENT_TYPE) + ) + finally: + req.close() @asyncio.coroutine diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 9b64c08af18a2..5db4ece5ef5a2 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -113,6 +113,62 @@ def template_condition_listener(entity_id, from_s, to_s): track_template = threaded_listener_factory(async_track_template) +@callback +def async_track_same_state(hass, orig_value, period, action, + async_check_func=None, entity_ids=MATCH_ALL): + """Track the state of entities for a period and run a action. + + If async_check_func is None it use the state of orig_value. + Without entity_ids we track all state changes. + """ + async_remove_state_for_cancel = None + async_remove_state_for_listener = None + + @callback + def clear_listener(): + """Clear all unsub listener.""" + nonlocal async_remove_state_for_cancel, async_remove_state_for_listener + + # pylint: disable=not-callable + if async_remove_state_for_listener is not None: + async_remove_state_for_listener() + async_remove_state_for_listener = None + if async_remove_state_for_cancel is not None: + async_remove_state_for_cancel() + async_remove_state_for_cancel = None + + @callback + def state_for_listener(now): + """Fire on state changes after a delay and calls action.""" + nonlocal async_remove_state_for_listener + async_remove_state_for_listener = None + clear_listener() + hass.async_run_job(action) + + @callback + def state_for_cancel_listener(entity, from_state, to_state): + """Fire on changes and cancel for listener if changed.""" + if async_check_func: + value = async_check_func(entity, from_state, to_state) + else: + value = to_state.state + + if orig_value == value: + return + clear_listener() + + async_remove_state_for_listener = async_track_point_in_utc_time( + hass, state_for_listener, dt_util.utcnow() + period) + + async_remove_state_for_cancel = async_track_state_change( + hass, entity_ids, state_for_cancel_listener) + + return clear_listener + + +track_same_state = threaded_listener_factory(async_track_same_state) + + @callback def async_track_point_in_time(hass, action, point_in_time): """Add a listener that fires once after a specific point in time.""" diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 6c74c49424e9f..d5dbcb77a324e 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -10,7 +10,8 @@ from jinja2.sandbox import ImmutableSandboxedEnvironment from homeassistant.const import ( - STATE_UNKNOWN, ATTR_LATITUDE, ATTR_LONGITUDE, MATCH_ALL) + STATE_UNKNOWN, ATTR_LATITUDE, ATTR_LONGITUDE, MATCH_ALL, + ATTR_UNIT_OF_MEASUREMENT) from homeassistant.core import State from homeassistant.exceptions import TemplateError from homeassistant.helpers import location as loc_helper @@ -181,8 +182,14 @@ def __getattr__(self, name): def __iter__(self): """Return all states.""" - return iter(sorted(self._hass.states.async_all(), - key=lambda state: state.entity_id)) + return iter( + _wrap_state(state) for state in + sorted(self._hass.states.async_all(), + key=lambda state: state.entity_id)) + + def __len__(self): + """Return number of states.""" + return len(self._hass.states.async_entity_ids()) def __call__(self, entity_id): """Return the states.""" @@ -200,15 +207,56 @@ def __init__(self, hass, domain): def __getattr__(self, name): """Return the states.""" - return self._hass.states.get('{}.{}'.format(self._domain, name)) + return _wrap_state( + self._hass.states.get('{}.{}'.format(self._domain, name))) def __iter__(self): """Return the iteration over all the states.""" return iter(sorted( - (state for state in self._hass.states.async_all() + (_wrap_state(state) for state in self._hass.states.async_all() if state.domain == self._domain), key=lambda state: state.entity_id)) + def __len__(self): + """Return number of states.""" + return len(self._hass.states.async_entity_ids(self._domain)) + + +class TemplateState(State): + """Class to represent a state object in a template.""" + + # Inheritance is done so functions that check against State keep working + # pylint: disable=super-init-not-called + def __init__(self, state): + """Initialize template state.""" + self._state = state + + @property + def state_with_unit(self): + """Return the state concatenated with the unit if available.""" + state = object.__getattribute__(self, '_state') + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if unit is None: + return state.state + return "{} {}".format(state.state, unit) + + def __getattribute__(self, name): + """Return an attribute of the state.""" + if name in TemplateState.__dict__: + return object.__getattribute__(self, name) + else: + return getattr(object.__getattribute__(self, '_state'), name) + + def __repr__(self): + """Representation of Template State.""" + rep = object.__getattribute__(self, '_state').__repr__() + return '