diff --git a/.coveragerc b/.coveragerc index 8867a0837aa..530d7832d81 100644 --- a/.coveragerc +++ b/.coveragerc @@ -4,6 +4,7 @@ source = homeassistant omit = homeassistant/__main__.py homeassistant/scripts/*.py + homeassistant/helpers/typing.py # omit pieces of code that rely on external devices being present homeassistant/components/apcupsd.py @@ -88,6 +89,9 @@ omit = homeassistant/components/homematic.py homeassistant/components/*/homematic.py + homeassistant/components/pilight.py + homeassistant/components/*/pilight.py + homeassistant/components/knx.py homeassistant/components/switch/knx.py homeassistant/components/binary_sensor/knx.py @@ -100,6 +104,7 @@ omit = homeassistant/components/binary_sensor/rest.py homeassistant/components/browser.py homeassistant/components/camera/bloomsky.py + homeassistant/components/camera/ffmpeg.py homeassistant/components/camera/foscam.py homeassistant/components/camera/generic.py homeassistant/components/camera/mjpeg.py @@ -123,8 +128,9 @@ omit = homeassistant/components/discovery.py homeassistant/components/downloader.py homeassistant/components/feedreader.py - homeassistant/components/garage_door/wink.py + homeassistant/components/foursquare.py homeassistant/components/garage_door/rpi_gpio.py + homeassistant/components/garage_door/wink.py homeassistant/components/hdmi_cec.py homeassistant/components/ifttt.py homeassistant/components/joaoapps_join.py @@ -192,23 +198,27 @@ omit = homeassistant/components/sensor/dte_energy_bridge.py homeassistant/components/sensor/efergy.py homeassistant/components/sensor/eliqonline.py + homeassistant/components/sensor/fastdotcom.py homeassistant/components/sensor/fitbit.py homeassistant/components/sensor/fixer.py homeassistant/components/sensor/forecast.py homeassistant/components/sensor/glances.py homeassistant/components/sensor/google_travel_time.py + homeassistant/components/sensor/gpsd.py homeassistant/components/sensor/gtfs.py homeassistant/components/sensor/imap.py homeassistant/components/sensor/lastfm.py homeassistant/components/sensor/loopenergy.py homeassistant/components/sensor/neurio_energy.py homeassistant/components/sensor/nzbget.py + homeassistant/components/sensor/ohmconnect.py homeassistant/components/sensor/onewire.py - homeassistant/components/sensor/openweathermap.py homeassistant/components/sensor/openexchangerates.py + homeassistant/components/sensor/openweathermap.py homeassistant/components/sensor/plex.py homeassistant/components/sensor/rest.py homeassistant/components/sensor/sabnzbd.py + homeassistant/components/sensor/serial_pm.py homeassistant/components/sensor/snmp.py homeassistant/components/sensor/speedtest.py homeassistant/components/sensor/steam_online.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..e64c35dd6b8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.tox +.git diff --git a/.gitignore b/.gitignore index 64ab38f2da8..b73dcef1073 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,9 @@ config/custom_components/* !config/custom_components/example.py !config/custom_components/hello_world.py !config/custom_components/mqtt_example.py -!config/custom_components/react_panel +!config/panels +config/panels/* +!config/panels/react.html tests/testing_config/deps tests/testing_config/home-assistant.log @@ -52,7 +54,8 @@ develop-eggs lib lib64 -# Installer logs +# Logs +*.log pip-log.txt # Unit test / coverage reports @@ -91,3 +94,6 @@ ctags.tmp virtualization/vagrant/setup_done virtualization/vagrant/.vagrant virtualization/vagrant/config + +# Visual Studio Code +.vscode \ No newline at end of file diff --git a/config/configuration.yaml.example b/config/configuration.yaml.example index abf4bd5b035..73c03f94144 100644 --- a/config/configuration.yaml.example +++ b/config/configuration.yaml.example @@ -10,8 +10,8 @@ homeassistant: # Impacts weather/sunrise data elevation: 665 - # C for Celsius, F for Fahrenheit - temperature_unit: C + # 'metric' for Metric System, 'imperial' for imperial system + unit_system: metric # Pick yours from here: # http://en.wikipedia.org/wiki/List_of_tz_database_time_zones diff --git a/config/custom_components/react_panel/__init__.py b/config/custom_components/react_panel/__init__.py deleted file mode 100644 index 57073b8cddc..00000000000 --- a/config/custom_components/react_panel/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Custom panel example showing TodoMVC using React. - -Will add a panel to control lights and switches using React. Allows configuring -the title via configuration.yaml: - -react_panel: - title: 'home' - -""" -import os - -from homeassistant.components.frontend import register_panel - -DOMAIN = 'react_panel' -DEPENDENCIES = ['frontend'] - -PANEL_PATH = os.path.join(os.path.dirname(__file__), 'panel.html') - - -def setup(hass, config): - """Initialize custom panel.""" - title = config.get(DOMAIN, {}).get('title') - - config = None if title is None else {'title': title} - - register_panel(hass, 'react', PANEL_PATH, - title='TodoMVC', icon='mdi:checkbox-marked-outline', - config=config) - return True diff --git a/config/custom_components/react_panel/panel.html b/config/panels/react.html similarity index 96% rename from config/custom_components/react_panel/panel.html rename to config/panels/react.html index eceee0f0616..dc2735cf759 100644 --- a/config/custom_components/react_panel/panel.html +++ b/config/panels/react.html @@ -1,3 +1,20 @@ + + diff --git a/docs/swagger.yaml b/docs/swagger.yaml index ac5a5b50599..b0d765be361 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -419,8 +419,9 @@ definitions: description: Longitude of Home Assistant server location_name: type: string - temperature_unit: + unit_system: type: string + description: The system for measurement units time_zone: type: string version: diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index fb1594d5b3f..39a18feb1f2 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -274,7 +274,7 @@ def try_to_restart() -> None: # thread left (which is us). Nothing we really do with it, but it might be # useful when debugging shutdown/restart issues. try: - nthreads = sum(thread.isAlive() and not thread.isDaemon() + nthreads = sum(thread.is_alive() and not thread.daemon for thread in threading.enumerate()) if nthreads > 1: sys.stderr.write( diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index c62fe9e7d6b..c7fb9096a56 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -11,12 +11,12 @@ from types import ModuleType from typing import Any, Optional, Dict import voluptuous as vol +from voluptuous.humanize import humanize_error import homeassistant.components as core_components from homeassistant.components import group, persistent_notification import homeassistant.config as conf_util import homeassistant.core as core -import homeassistant.helpers.config_validation as cv import homeassistant.loader as loader import homeassistant.util.package as pkg_util from homeassistant.const import EVENT_COMPONENT_LOADED, PLATFORM_FORMAT @@ -103,7 +103,7 @@ def _setup_component(hass: core.HomeAssistant, domain: str, config) -> bool: try: config = component.CONFIG_SCHEMA(config) except vol.MultipleInvalid as ex: - cv.log_exception(_LOGGER, ex, domain, config) + _log_exception(ex, domain, config) return False elif hasattr(component, 'PLATFORM_SCHEMA'): @@ -113,7 +113,7 @@ def _setup_component(hass: core.HomeAssistant, domain: str, config) -> bool: try: p_validated = component.PLATFORM_SCHEMA(p_config) except vol.MultipleInvalid as ex: - cv.log_exception(_LOGGER, ex, domain, p_config) + _log_exception(ex, domain, p_config) return False # Not all platform components follow same pattern for platforms @@ -134,8 +134,8 @@ def _setup_component(hass: core.HomeAssistant, domain: str, config) -> bool: try: p_validated = platform.PLATFORM_SCHEMA(p_validated) except vol.MultipleInvalid as ex: - cv.log_exception(_LOGGER, ex, '{}.{}' - .format(domain, p_name), p_validated) + _log_exception(ex, '{}.{}'.format(domain, p_name), + p_validated) return False platforms.append(p_validated) @@ -232,14 +232,14 @@ def from_config_dict(config: Dict[str, Any], if config_dir is not None: config_dir = os.path.abspath(config_dir) hass.config.config_dir = config_dir - _mount_local_lib_path(config_dir) + mount_local_lib_path(config_dir) core_config = config.get(core.DOMAIN, {}) try: conf_util.process_ha_core_config(hass, core_config) except vol.Invalid as ex: - cv.log_exception(_LOGGER, ex, 'homeassistant', core_config) + _log_exception(ex, 'homeassistant', core_config) return None conf_util.process_ha_config_upgrade(hass) @@ -300,7 +300,7 @@ def from_config_file(config_path: str, # Set config dir to directory holding config file config_dir = os.path.abspath(os.path.dirname(config_path)) hass.config.config_dir = config_dir - _mount_local_lib_path(config_dir) + mount_local_lib_path(config_dir) enable_logging(hass, verbose, log_rotate_days) @@ -371,6 +371,26 @@ def _ensure_loader_prepared(hass: core.HomeAssistant) -> None: loader.prepare(hass) -def _mount_local_lib_path(config_dir: str) -> None: +def _log_exception(ex, domain, config): + """Generate log exception for config validation.""" + message = 'Invalid config for [{}]: '.format(domain) + if 'extra keys not allowed' in ex.error_message: + message += '[{}] is an invalid option for [{}]. Check: {}->{}.'\ + .format(ex.path[-1], domain, domain, + '->'.join('%s' % m for m in ex.path)) + else: + message += humanize_error(config, ex) + + if hasattr(config, '__line__'): + message += " (See {}:{})".format(config.__config_file__, + config.__line__ or '?') + + _LOGGER.error(message) + + +def mount_local_lib_path(config_dir: str) -> str: """Add local library to Python Path.""" - sys.path.insert(0, os.path.join(config_dir, 'deps')) + deps_dir = os.path.join(config_dir, 'deps') + if deps_dir not in sys.path: + sys.path.insert(0, os.path.join(config_dir, 'deps')) + return deps_dir diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index 38780ed9b28..7d025bac765 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -11,7 +11,6 @@ import itertools as it import logging import homeassistant.core as ha -from homeassistant.helpers.entity import split_entity_id from homeassistant.helpers.service import extract_entity_ids from homeassistant.loader import get_component from homeassistant.const import ( @@ -35,7 +34,7 @@ def is_on(hass, entity_id=None): entity_ids = hass.states.entity_ids() for entity_id in entity_ids: - domain = split_entity_id(entity_id)[0] + domain = ha.split_entity_id(entity_id)[0] module = get_component(domain) @@ -95,7 +94,7 @@ def setup(hass, config): # Group entity_ids by domain. groupby requires sorted data. by_domain = it.groupby(sorted(entity_ids), - lambda item: split_entity_id(item)[0]) + lambda item: ha.split_entity_id(item)[0]) for domain, ent_ids in by_domain: # We want to block for all calls and only return when all calls diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 29b2d47fd20..2030c8f88d8 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -20,6 +20,7 @@ from homeassistant.helpers.entity_component import EntityComponent DOMAIN = 'alarm_control_panel' SCAN_INTERVAL = 30 +ATTR_CHANGED_BY = 'changed_by' ENTITY_ID_FORMAT = DOMAIN + '.{}' @@ -124,6 +125,11 @@ class AlarmControlPanel(Entity): """Regex for code format or None if no code is required.""" return None + @property + def changed_by(self): + """Last change triggered by.""" + return None + def alarm_disarm(self, code=None): """Send disarm command.""" raise NotImplementedError() @@ -145,5 +151,6 @@ class AlarmControlPanel(Entity): """Return the state attributes.""" state_attr = { ATTR_CODE_FORMAT: self.code_format, + ATTR_CHANGED_BY: self.changed_by } return state_attr diff --git a/homeassistant/components/alarm_control_panel/services.yaml b/homeassistant/components/alarm_control_panel/services.yaml index e69de29bb2d..40188e32d99 100644 --- a/homeassistant/components/alarm_control_panel/services.yaml +++ b/homeassistant/components/alarm_control_panel/services.yaml @@ -0,0 +1,43 @@ +alarm_disarm: + description: Send the alarm the command for disarm + + fields: + entity_id: + description: Name of alarm control panel to disarm + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to disarm the alarm control panel with + example: 1234 + +alarm_arm_home: + description: Send the alarm the command for arm home + + fields: + entity_id: + description: Name of alarm control panel to arm home + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm home the alarm control panel with + example: 1234 + +alarm_arm_away: + description: Send the alarm the command for arm away + + fields: + entity_id: + description: Name of alarm control panel to arm away + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm away the alarm control panel with + example: 1234 + +alarm_trigger: + description: Send the alarm the command for trigger + + fields: + entity_id: + description: Name of alarm control panel to trigger + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to trigger the alarm control panel with + example: 1234 diff --git a/homeassistant/components/alarm_control_panel/verisure.py b/homeassistant/components/alarm_control_panel/verisure.py index 4a49e0b6941..ee1ccfc1bd0 100644 --- a/homeassistant/components/alarm_control_panel/verisure.py +++ b/homeassistant/components/alarm_control_panel/verisure.py @@ -37,6 +37,7 @@ class VerisureAlarm(alarm.AlarmControlPanel): self._id = device_id self._state = STATE_UNKNOWN self._digits = int(hub.config.get('code_digits', '4')) + self._changed_by = None @property def name(self): @@ -58,6 +59,11 @@ class VerisureAlarm(alarm.AlarmControlPanel): """The code format as regex.""" return '^\\d{%s}$' % self._digits + @property + def changed_by(self): + """Last change triggered by.""" + return self._changed_by + def update(self): """Update alarm status.""" hub.update_alarms() @@ -72,6 +78,7 @@ class VerisureAlarm(alarm.AlarmControlPanel): _LOGGER.error( 'Unknown alarm state %s', hub.alarm_status[self._id].status) + self._changed_by = hub.alarm_status[self._id].name def alarm_disarm(self, code=None): """Send disarm command.""" diff --git a/homeassistant/components/camera/ffmpeg.py b/homeassistant/components/camera/ffmpeg.py new file mode 100644 index 00000000000..6803ebb49a3 --- /dev/null +++ b/homeassistant/components/camera/ffmpeg.py @@ -0,0 +1,75 @@ +""" +Support for Cameras with FFmpeg as decoder. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.ffmpeg/ +""" +import logging +from contextlib import closing + +import voluptuous as vol + +from homeassistant.components.camera import Camera +from homeassistant.components.camera.mjpeg import extract_image_from_mjpeg +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_NAME, CONF_PLATFORM + +REQUIREMENTS = ["ha-ffmpeg==0.4"] + +CONF_INPUT = 'input' +CONF_FFMPEG_BIN = 'ffmpeg_bin' +CONF_EXTRA_ARGUMENTS = 'extra_arguments' + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): "ffmpeg", + vol.Optional(CONF_NAME, default="FFmpeg"): cv.string, + vol.Required(CONF_INPUT): cv.string, + vol.Optional(CONF_FFMPEG_BIN, default="ffmpeg"): cv.string, + vol.Optional(CONF_EXTRA_ARGUMENTS): cv.string, +}) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup a FFmpeg Camera.""" + add_devices_callback([FFmpegCamera(config)]) + + +class FFmpegCamera(Camera): + """An implementation of an FFmpeg camera.""" + + def __init__(self, config): + """Initialize a FFmpeg camera.""" + super().__init__() + self._name = config.get(CONF_NAME) + self._input = config.get(CONF_INPUT) + self._extra_arguments = config.get(CONF_EXTRA_ARGUMENTS) + self._ffmpeg_bin = config.get(CONF_FFMPEG_BIN) + + def _ffmpeg_stream(self): + """Return a FFmpeg process object.""" + from haffmpeg import CameraMjpeg + + ffmpeg = CameraMjpeg(self._ffmpeg_bin) + ffmpeg.open_camera(self._input, extra_cmd=self._extra_arguments) + return ffmpeg + + def camera_image(self): + """Return a still image response from the camera.""" + with closing(self._ffmpeg_stream()) as stream: + return extract_image_from_mjpeg(stream) + + def mjpeg_stream(self, response): + """Generate an HTTP MJPEG stream from the camera.""" + stream = self._ffmpeg_stream() + return response( + stream, + mimetype='multipart/x-mixed-replace;boundary=ffserver', + direct_passthrough=True + ) + + @property + def name(self): + """Return the name of this camera.""" + return self._name diff --git a/homeassistant/components/camera/mjpeg.py b/homeassistant/components/camera/mjpeg.py index b7f31404a8f..dce8ac30440 100644 --- a/homeassistant/components/camera/mjpeg.py +++ b/homeassistant/components/camera/mjpeg.py @@ -28,6 +28,18 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): add_devices_callback([MjpegCamera(config)]) +def extract_image_from_mjpeg(stream): + """Take in a MJPEG stream object, return the jpg from it.""" + data = b'' + for chunk in stream: + data += chunk + jpg_start = data.find(b'\xff\xd8') + jpg_end = data.find(b'\xff\xd9') + if jpg_start != -1 and jpg_end != -1: + jpg = data[jpg_start:jpg_end + 2] + return jpg + + # pylint: disable=too-many-instance-attributes class MjpegCamera(Camera): """An implementation of an IP camera that is reachable over a URL.""" @@ -52,19 +64,8 @@ class MjpegCamera(Camera): def camera_image(self): """Return a still image response from the camera.""" - def process_response(response): - """Take in a response object, return the jpg from it.""" - data = b'' - for chunk in response.iter_content(1024): - data += chunk - jpg_start = data.find(b'\xff\xd8') - jpg_end = data.find(b'\xff\xd9') - if jpg_start != -1 and jpg_end != -1: - jpg = data[jpg_start:jpg_end + 2] - return jpg - with closing(self.camera_stream()) as response: - return process_response(response) + return extract_image_from_mjpeg(response.iter_content(1024)) def mjpeg_stream(self, response): """Generate an HTTP MJPEG stream from the camera.""" diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py index 6a5d3a0d93c..6b580fa21fb 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation.py @@ -27,7 +27,7 @@ SERVICE_PROCESS_SCHEMA = vol.Schema({ REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') -REQUIREMENTS = ['fuzzywuzzy==0.11.0'] +REQUIREMENTS = ['fuzzywuzzy==0.11.1'] def setup(hass, config): diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py index 29e5773c9a6..7b9f2e9036b 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -21,10 +21,10 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) -# interval in minutes to exclude devices from a scan while they are home +# Interval in minutes to exclude devices from a scan while they are home CONF_HOME_INTERVAL = "home_interval" -REQUIREMENTS = ['python-nmap==0.6.0'] +REQUIREMENTS = ['python-nmap==0.6.1'] def get_scanner(hass, config): diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index ac01202c7fb..6db4af66207 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -13,7 +13,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.helpers.discovery import load_platform, discover DOMAIN = "discovery" -REQUIREMENTS = ['netdisco==0.7.0'] +REQUIREMENTS = ['netdisco==0.7.1'] SCAN_INTERVAL = 300 # seconds diff --git a/homeassistant/components/foursquare.py b/homeassistant/components/foursquare.py new file mode 100644 index 00000000000..6fcd2312bab --- /dev/null +++ b/homeassistant/components/foursquare.py @@ -0,0 +1,99 @@ +""" +Allows utilizing the Foursquare (Swarm) API. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/foursquare/ +""" +import logging +import os +import json +import requests + +import voluptuous as vol + +from homeassistant.config import load_yaml_config_file +import homeassistant.helpers.config_validation as cv +from homeassistant.components.http import HomeAssistantView + +DOMAIN = "foursquare" + +SERVICE_CHECKIN = "checkin" + +EVENT_PUSH = "foursquare.push" +EVENT_CHECKIN = "foursquare.checkin" + +CHECKIN_SERVICE_SCHEMA = vol.Schema({ + vol.Required("venueId"): cv.string, + vol.Optional("eventId"): cv.string, + vol.Optional("shout"): cv.string, + vol.Optional("mentions"): cv.string, + vol.Optional("broadcast"): cv.string, + vol.Optional("ll"): cv.string, + vol.Optional("llAcc"): cv.string, + vol.Optional("alt"): cv.string, + vol.Optional("altAcc"): cv.string, +}) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ["http"] + + +def setup(hass, config): + """Setup the Foursquare component.""" + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), "services.yaml")) + + config = config[DOMAIN] + + def checkin_user(call): + """Check a user in on Swarm.""" + url = ("https://api.foursquare.com/v2/checkins/add" + "?oauth_token={}" + "&v=20160802" + "&m=swarm").format(config["access_token"]) + response = requests.post(url, data=call.data, timeout=10) + + if response.status_code not in (200, 201): + _LOGGER.exception( + "Error checking in user. Response %d: %s:", + response.status_code, response.reason) + + hass.bus.fire(EVENT_CHECKIN, response.text) + + # Register our service with Home Assistant. + hass.services.register(DOMAIN, "checkin", checkin_user, + descriptions[DOMAIN][SERVICE_CHECKIN], + schema=CHECKIN_SERVICE_SCHEMA) + + hass.wsgi.register_view(FoursquarePushReceiver(hass, + config["push_secret"])) + + return True + + +class FoursquarePushReceiver(HomeAssistantView): + """Handle pushes from the Foursquare API.""" + + requires_auth = False + url = "/api/foursquare" + name = "foursquare" + + def __init__(self, hass, push_secret): + """Initialize the OAuth callback view.""" + super().__init__(hass) + self.push_secret = push_secret + + def post(self, request): + """Accept the POST from Foursquare.""" + raw_data = request.form + _LOGGER.debug("Received Foursquare push: %s", raw_data) + if self.push_secret != raw_data["secret"]: + _LOGGER.error("Received Foursquare push with invalid" + "push secret! Data: %s", raw_data) + return + parsed_payload = { + key: json.loads(val) for key, val in raw_data.items() + if key != "secret" + } + self.hass.bus.fire(EVENT_PUSH, parsed_payload) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 3925170694e..cec18b66511 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -20,8 +20,8 @@ _REGISTERED_COMPONENTS = set() _LOGGER = logging.getLogger(__name__) -def register_built_in_panel(hass, component_name, title=None, icon=None, - url_name=None, config=None): +def register_built_in_panel(hass, component_name, sidebar_title=None, + sidebar_icon=None, url_path=None, config=None): """Register a built-in panel.""" # pylint: disable=too-many-arguments path = 'panels/ha-panel-{}.html'.format(component_name) @@ -33,30 +33,31 @@ def register_built_in_panel(hass, component_name, title=None, icon=None, url = None # use default url generate mechanism register_panel(hass, component_name, os.path.join(STATIC_PATH, path), - FINGERPRINTS[path], title, icon, url_name, url, config) + FINGERPRINTS[path], sidebar_title, sidebar_icon, url_path, + url, config) -def register_panel(hass, component_name, path, md5=None, title=None, icon=None, - url_name=None, url=None, config=None): +def register_panel(hass, component_name, path, md5=None, sidebar_title=None, + sidebar_icon=None, url_path=None, url=None, config=None): """Register a panel for the frontend. component_name: name of the web component path: path to the HTML of the web component md5: the md5 hash of the web component (for versioning, optional) - title: title to show in the sidebar (optional) - icon: icon to show next to title in sidebar (optional) - url_name: name to use in the url (defaults to component_name) + 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) config: config to be passed into the web component Warning: this API will probably change. Use at own risk. """ # pylint: disable=too-many-arguments - if url_name is None: - url_name = component_name + if url_path is None: + url_path = component_name - if url_name in PANELS: - _LOGGER.warning('Overwriting component %s', url_name) + 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) @@ -67,14 +68,14 @@ def register_panel(hass, component_name, path, md5=None, title=None, icon=None, md5 = hashlib.md5(fil.read().encode('utf-8')).hexdigest() data = { - 'url_name': url_name, + 'url_path': url_path, 'component_name': component_name, } - if title: - data['title'] = title - if icon: - data['icon'] = icon + if sidebar_title: + data['title'] = sidebar_title + if sidebar_icon: + data['icon'] = sidebar_icon if config is not None: data['config'] = config @@ -90,7 +91,7 @@ def register_panel(hass, component_name, path, md5=None, title=None, icon=None, fprinted_url = URL_PANEL_COMPONENT_FP.format(component_name, md5) data['url'] = fprinted_url - PANELS[url_name] = data + PANELS[url_path] = data def setup(hass, config): @@ -195,6 +196,6 @@ class IndexView(HomeAssistantView): resp = template.render( core_url=core_url, ui_url=ui_url, no_auth=no_auth, icons_url=icons_url, icons=FINGERPRINTS['mdi.html'], - panel_url=panel_url) + panel_url=panel_url, panels=PANELS) return self.Response(resp, mimetype='text/html') diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html index 31e347627fa..859b2af53f0 100644 --- a/homeassistant/components/frontend/templates/index.html +++ b/homeassistant/components/frontend/templates/index.html @@ -8,6 +8,9 @@ + {% for panel in panels.values() -%} + + {% endfor -%} @@ -86,9 +89,9 @@ {# #} - {% if panel_url %} + {% if panel_url -%} - {% endif %} + {% endif -%} \ No newline at end of file + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/frontend.html.gz b/homeassistant/components/frontend/www_static/frontend.html.gz index bbc4f6c09f9..5997604af41 100644 Binary files a/homeassistant/components/frontend/www_static/frontend.html.gz and b/homeassistant/components/frontend/www_static/frontend.html.gz differ diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index 697f9397de3..474366c536e 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 697f9397de357cec9662626575fc01d6f921ef22 +Subproject commit 474366c536ec3e471da12d5f15b07b79fe9b07e2 diff --git a/homeassistant/components/frontend/www_static/mdi.html b/homeassistant/components/frontend/www_static/mdi.html index 8bc40205f1b..8bc5ae36aef 100644 --- a/homeassistant/components/frontend/www_static/mdi.html +++ b/homeassistant/components/frontend/www_static/mdi.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/mdi.html.gz b/homeassistant/components/frontend/www_static/mdi.html.gz index e5e42c3f490..37bbb7ebf52 100644 Binary files a/homeassistant/components/frontend/www_static/mdi.html.gz and b/homeassistant/components/frontend/www_static/mdi.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html index 273947ef22f..bdf7f206ba3 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.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-dev-event.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz index 5c48f4f22ec..7ba8aeef59f 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html index d858d470d76..e255ca2f83d 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html @@ -1,2 +1,2 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz index 435c85a2e5c..737bbbd3f19 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html index d94782b8763..21db62aab76 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.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-dev-service.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz index 32922dfd3ee..3f13ec3811f 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html index 907e497c5d1..35caacb76b9 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.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-dev-state.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz index e356b098b3a..fce404f74bb 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.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 b7afc61f34c..bf2ff97c528 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 + \ 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 17a7e815912..0a1b22972c6 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-history.html b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html index b592685f173..95bfe398184 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html @@ -1,4 +1,4 @@ \ No newline at end of file + */.pika-single{z-index:9999;display:block;position:relative;color:#333;background:#fff;border:1px solid #ccc;border-bottom-color:#bbb;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif}.pika-single:after,.pika-single:before{content:" ";display:table}.pika-single:after{clear:both}.pika-single.is-hidden{display:none}.pika-single.is-bound{position:absolute;box-shadow:0 5px 15px -5px rgba(0,0,0,.5)}.pika-lendar{float:left;width:240px;margin:8px}.pika-title{position:relative;text-align:center}.pika-label{display:inline-block;position:relative;z-index:9999;overflow:hidden;margin:0;padding:5px 3px;font-size:14px;line-height:20px;font-weight:700;background-color:#fff}.pika-title select{cursor:pointer;position:absolute;z-index:9998;margin:0;left:0;top:5px;filter:alpha(opacity=0);opacity:0}.pika-next,.pika-prev{display:block;cursor:pointer;position:relative;outline:0;border:0;padding:0;width:20px;height:30px;text-indent:20px;white-space:nowrap;overflow:hidden;background-color:transparent;background-position:center center;background-repeat:no-repeat;background-size:75% 75%;opacity:.5}.pika-next:hover,.pika-prev:hover{opacity:1}.is-rtl .pika-next,.pika-prev{float:left;background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAAUklEQVR42u3VMQoAIBADQf8Pgj+OD9hG2CtONJB2ymQkKe0HbwAP0xucDiQWARITIDEBEnMgMQ8S8+AqBIl6kKgHiXqQqAeJepBo/z38J/U0uAHlaBkBl9I4GwAAAABJRU5ErkJggg==)}.is-rtl .pika-prev,.pika-next{float:right;background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAAU0lEQVR42u3VOwoAMAgE0dwfAnNjU26bYkBCFGwfiL9VVWoO+BJ4Gf3gtsEKKoFBNTCoCAYVwaAiGNQGMUHMkjGbgjk2mIONuXo0nC8XnCf1JXgArVIZAQh5TKYAAAAASUVORK5CYII=)}.pika-next.is-disabled,.pika-prev.is-disabled{cursor:default;opacity:.2}.pika-select{display:inline-block}.pika-table{width:100%;border-collapse:collapse;border-spacing:0;border:0}.pika-table td,.pika-table th{width:14.285714285714286%;padding:0}.pika-table th{color:#999;font-size:12px;line-height:25px;font-weight:700;text-align:center}.pika-button{cursor:pointer;display:block;box-sizing:border-box;-moz-box-sizing:border-box;outline:0;border:0;margin:0;width:100%;padding:5px;color:#666;font-size:12px;line-height:15px;text-align:right;background:#f5f5f5}.pika-week{font-size:11px;color:#999}.is-today .pika-button{color:#3af;font-weight:700}.is-selected .pika-button{color:#fff;font-weight:700;background:#3af;box-shadow:inset 0 1px 3px #178fe5;border-radius:3px}.is-inrange .pika-button{background:#D5E9F7}.is-startrange .pika-button{color:#fff;background:#6CB31D;box-shadow:none;border-radius:3px}.is-endrange .pika-button{color:#fff;background:#3af;box-shadow:none;border-radius:3px}.is-disabled .pika-button{pointer-events:none;cursor:default;color:#999;opacity:.3}.pika-button:hover{color:#fff;background:#ff8000;box-shadow:none;border-radius:3px}.pika-table abbr{border-bottom:none;cursor:help}} \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz index 061db66c3c1..b56925edc47 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html index 6b0731f0016..ff2cdbfe4b4 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.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-iframe.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz index 2f23d270f6b..e48eabf9a6d 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html index bc6dc384245..4b7d00dc66a 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html @@ -1,4 +1,4 @@ \ No newline at end of file + */.pika-single{z-index:9999;display:block;position:relative;color:#333;background:#fff;border:1px solid #ccc;border-bottom-color:#bbb;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif}.pika-single:after,.pika-single:before{content:" ";display:table}.pika-single:after{clear:both}.pika-single.is-hidden{display:none}.pika-single.is-bound{position:absolute;box-shadow:0 5px 15px -5px rgba(0,0,0,.5)}.pika-lendar{float:left;width:240px;margin:8px}.pika-title{position:relative;text-align:center}.pika-label{display:inline-block;position:relative;z-index:9999;overflow:hidden;margin:0;padding:5px 3px;font-size:14px;line-height:20px;font-weight:700;background-color:#fff}.pika-title select{cursor:pointer;position:absolute;z-index:9998;margin:0;left:0;top:5px;filter:alpha(opacity=0);opacity:0}.pika-next,.pika-prev{display:block;cursor:pointer;position:relative;outline:0;border:0;padding:0;width:20px;height:30px;text-indent:20px;white-space:nowrap;overflow:hidden;background-color:transparent;background-position:center center;background-repeat:no-repeat;background-size:75% 75%;opacity:.5}.pika-next:hover,.pika-prev:hover{opacity:1}.is-rtl .pika-next,.pika-prev{float:left;background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAAUklEQVR42u3VMQoAIBADQf8Pgj+OD9hG2CtONJB2ymQkKe0HbwAP0xucDiQWARITIDEBEnMgMQ8S8+AqBIl6kKgHiXqQqAeJepBo/z38J/U0uAHlaBkBl9I4GwAAAABJRU5ErkJggg==)}.is-rtl .pika-prev,.pika-next{float:right;background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAAU0lEQVR42u3VOwoAMAgE0dwfAnNjU26bYkBCFGwfiL9VVWoO+BJ4Gf3gtsEKKoFBNTCoCAYVwaAiGNQGMUHMkjGbgjk2mIONuXo0nC8XnCf1JXgArVIZAQh5TKYAAAAASUVORK5CYII=)}.pika-next.is-disabled,.pika-prev.is-disabled{cursor:default;opacity:.2}.pika-select{display:inline-block}.pika-table{width:100%;border-collapse:collapse;border-spacing:0;border:0}.pika-table td,.pika-table th{width:14.285714285714286%;padding:0}.pika-table th{color:#999;font-size:12px;line-height:25px;font-weight:700;text-align:center}.pika-button{cursor:pointer;display:block;box-sizing:border-box;-moz-box-sizing:border-box;outline:0;border:0;margin:0;width:100%;padding:5px;color:#666;font-size:12px;line-height:15px;text-align:right;background:#f5f5f5}.pika-week{font-size:11px;color:#999}.is-today .pika-button{color:#3af;font-weight:700}.is-selected .pika-button{color:#fff;font-weight:700;background:#3af;box-shadow:inset 0 1px 3px #178fe5;border-radius:3px}.is-inrange .pika-button{background:#D5E9F7}.is-startrange .pika-button{color:#fff;background:#6CB31D;box-shadow:none;border-radius:3px}.is-endrange .pika-button{color:#fff;background:#3af;box-shadow:none;border-radius:3px}.is-disabled .pika-button{pointer-events:none;cursor:default;color:#999;opacity:.3}.pika-button:hover{color:#fff;background:#ff8000;box-shadow:none;border-radius:3px}.pika-table abbr{border-bottom:none;cursor:help}} \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz index b25b29bc2c0..725c5f2b91d 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.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 ba5d18e47dc..296b14bd858 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html @@ -1,4 +1,4 @@ \ No newline at end of file +case"touchend":return this.addPointerListenerEnd(t,e,i,n);case"touchmove":return this.addPointerListenerMove(t,e,i,n);default:throw"Unknown touch event type"}},addPointerListenerStart:function(t,i,n,s){var a="_leaflet_",r=this._pointers,h=function(t){"mouse"!==t.pointerType&&t.pointerType!==t.MSPOINTER_TYPE_MOUSE&&o.DomEvent.preventDefault(t);for(var e=!1,i=0;i1))&&(this._moved||(o.DomUtil.addClass(e._mapPane,"leaflet-touching"),e.fire("movestart").fire("zoomstart"),this._moved=!0),o.Util.cancelAnimFrame(this._animRequest),this._animRequest=o.Util.requestAnimFrame(this._updateOnMove,this,!0,this._map._container),o.DomEvent.preventDefault(t))}},_updateOnMove:function(){var t=this._map,e=this._getScaleOrigin(),i=t.layerPointToLatLng(e),n=t.getScaleZoom(this._scale);t._animateZoom(i,n,this._startCenter,this._scale,this._delta,!1,!0)},_onTouchEnd:function(){if(!this._moved||!this._zooming)return void(this._zooming=!1);var t=this._map;this._zooming=!1,o.DomUtil.removeClass(t._mapPane,"leaflet-touching"),o.Util.cancelAnimFrame(this._animRequest),o.DomEvent.off(e,"touchmove",this._onTouchMove).off(e,"touchend",this._onTouchEnd);var i=this._getScaleOrigin(),n=t.layerPointToLatLng(i),s=t.getZoom(),a=t.getScaleZoom(this._scale)-s,r=a>0?Math.ceil(a):Math.floor(a),h=t._limitZoom(s+r),l=t.getZoomScale(h)/this._scale;t._animateZoom(n,h,i,l)},_getScaleOrigin:function(){var t=this._centerOffset.subtract(this._delta).divideBy(this._scale);return this._startCenter.add(t)}}),o.Map.addInitHook("addHandler","touchZoom",o.Map.TouchZoom),o.Map.mergeOptions({tap:!0,tapTolerance:15}),o.Map.Tap=o.Handler.extend({addHooks:function(){o.DomEvent.on(this._map._container,"touchstart",this._onDown,this)},removeHooks:function(){o.DomEvent.off(this._map._container,"touchstart",this._onDown,this)},_onDown:function(t){if(t.touches){if(o.DomEvent.preventDefault(t),this._fireClick=!0,t.touches.length>1)return this._fireClick=!1,void clearTimeout(this._holdTimeout);var i=t.touches[0],n=i.target;this._startPos=this._newPos=new o.Point(i.clientX,i.clientY),n.tagName&&"a"===n.tagName.toLowerCase()&&o.DomUtil.addClass(n,"leaflet-active"),this._holdTimeout=setTimeout(o.bind(function(){this._isTapValid()&&(this._fireClick=!1,this._onUp(),this._simulateEvent("contextmenu",i))},this),1e3),o.DomEvent.on(e,"touchmove",this._onMove,this).on(e,"touchend",this._onUp,this)}},_onUp:function(t){if(clearTimeout(this._holdTimeout),o.DomEvent.off(e,"touchmove",this._onMove,this).off(e,"touchend",this._onUp,this),this._fireClick&&t&&t.changedTouches){var i=t.changedTouches[0],n=i.target;n&&n.tagName&&"a"===n.tagName.toLowerCase()&&o.DomUtil.removeClass(n,"leaflet-active"),this._isTapValid()&&this._simulateEvent("click",i)}},_isTapValid:function(){return this._newPos.distanceTo(this._startPos)<=this._map.options.tapTolerance},_onMove:function(t){var e=t.touches[0];this._newPos=new o.Point(e.clientX,e.clientY)},_simulateEvent:function(i,n){var o=e.createEvent("MouseEvents");o._simulated=!0,n.target._simulatedClick=!0,o.initMouseEvent(i,!0,!0,t,1,n.screenX,n.screenY,n.clientX,n.clientY,!1,!1,!1,!1,0,null),n.target.dispatchEvent(o)}}),o.Browser.touch&&!o.Browser.pointer&&o.Map.addInitHook("addHandler","tap",o.Map.Tap),o.Map.mergeOptions({boxZoom:!0}),o.Map.BoxZoom=o.Handler.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._moved=!1},addHooks:function(){o.DomEvent.on(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){o.DomEvent.off(this._container,"mousedown",this._onMouseDown),this._moved=!1},moved:function(){return this._moved},_onMouseDown:function(t){return this._moved=!1,!(!t.shiftKey||1!==t.which&&1!==t.button)&&(o.DomUtil.disableTextSelection(),o.DomUtil.disableImageDrag(),this._startLayerPoint=this._map.mouseEventToLayerPoint(t),void o.DomEvent.on(e,"mousemove",this._onMouseMove,this).on(e,"mouseup",this._onMouseUp,this).on(e,"keydown",this._onKeyDown,this))},_onMouseMove:function(t){this._moved||(this._box=o.DomUtil.create("div","leaflet-zoom-box",this._pane),o.DomUtil.setPosition(this._box,this._startLayerPoint),this._container.style.cursor="crosshair",this._map.fire("boxzoomstart"));var e=this._startLayerPoint,i=this._box,n=this._map.mouseEventToLayerPoint(t),s=n.subtract(e),a=new o.Point(Math.min(n.x,e.x),Math.min(n.y,e.y));o.DomUtil.setPosition(i,a),this._moved=!0,i.style.width=Math.max(0,Math.abs(s.x)-4)+"px",i.style.height=Math.max(0,Math.abs(s.y)-4)+"px"},_finish:function(){this._moved&&(this._pane.removeChild(this._box),this._container.style.cursor=""),o.DomUtil.enableTextSelection(),o.DomUtil.enableImageDrag(),o.DomEvent.off(e,"mousemove",this._onMouseMove).off(e,"mouseup",this._onMouseUp).off(e,"keydown",this._onKeyDown)},_onMouseUp:function(t){this._finish();var e=this._map,i=e.mouseEventToLayerPoint(t);if(!this._startLayerPoint.equals(i)){var n=new o.LatLngBounds(e.layerPointToLatLng(this._startLayerPoint),e.layerPointToLatLng(i));e.fitBounds(n),e.fire("boxzoomend",{boxZoomBounds:n})}},_onKeyDown:function(t){27===t.keyCode&&this._finish()}}),o.Map.addInitHook("addHandler","boxZoom",o.Map.BoxZoom),o.Map.mergeOptions({keyboard:!0,keyboardPanOffset:80,keyboardZoomOffset:1}),o.Map.Keyboard=o.Handler.extend({keyCodes:{left:[37],right:[39],down:[40],up:[38],zoomIn:[187,107,61,171],zoomOut:[189,109,173]},initialize:function(t){this._map=t,this._setPanOffset(t.options.keyboardPanOffset),this._setZoomOffset(t.options.keyboardZoomOffset)},addHooks:function(){var t=this._map._container;-1===t.tabIndex&&(t.tabIndex="0"),o.DomEvent.on(t,"focus",this._onFocus,this).on(t,"blur",this._onBlur,this).on(t,"mousedown",this._onMouseDown,this),this._map.on("focus",this._addHooks,this).on("blur",this._removeHooks,this)},removeHooks:function(){this._removeHooks();var t=this._map._container;o.DomEvent.off(t,"focus",this._onFocus,this).off(t,"blur",this._onBlur,this).off(t,"mousedown",this._onMouseDown,this),this._map.off("focus",this._addHooks,this).off("blur",this._removeHooks,this)},_onMouseDown:function(){if(!this._focused){var i=e.body,n=e.documentElement,o=i.scrollTop||n.scrollTop,s=i.scrollLeft||n.scrollLeft;this._map._container.focus(),t.scrollTo(s,o)}},_onFocus:function(){this._focused=!0,this._map.fire("focus")},_onBlur:function(){this._focused=!1,this._map.fire("blur")},_setPanOffset:function(t){var e,i,n=this._panKeys={},o=this.keyCodes;for(e=0,i=o.left.length;i>e;e++)n[o.left[e]]=[-1*t,0];for(e=0,i=o.right.length;i>e;e++)n[o.right[e]]=[t,0];for(e=0,i=o.down.length;i>e;e++)n[o.down[e]]=[0,t];for(e=0,i=o.up.length;i>e;e++)n[o.up[e]]=[0,-1*t]},_setZoomOffset:function(t){var e,i,n=this._zoomKeys={},o=this.keyCodes;for(e=0,i=o.zoomIn.length;i>e;e++)n[o.zoomIn[e]]=t;for(e=0,i=o.zoomOut.length;i>e;e++)n[o.zoomOut[e]]=-t},_addHooks:function(){o.DomEvent.on(e,"keydown",this._onKeyDown,this)},_removeHooks:function(){o.DomEvent.off(e,"keydown",this._onKeyDown,this)},_onKeyDown:function(t){var e=t.keyCode,i=this._map;if(e in this._panKeys){if(i._panAnim&&i._panAnim._inProgress)return;i.panBy(this._panKeys[e]),i.options.maxBounds&&i.panInsideBounds(i.options.maxBounds)}else{if(!(e in this._zoomKeys))return;i.setZoom(i.getZoom()+this._zoomKeys[e])}o.DomEvent.stop(t)}}),o.Map.addInitHook("addHandler","keyboard",o.Map.Keyboard),o.Handler.MarkerDrag=o.Handler.extend({initialize:function(t){this._marker=t},addHooks:function(){var t=this._marker._icon;this._draggable||(this._draggable=new o.Draggable(t,t)),this._draggable.on("dragstart",this._onDragStart,this).on("drag",this._onDrag,this).on("dragend",this._onDragEnd,this),this._draggable.enable(),o.DomUtil.addClass(this._marker._icon,"leaflet-marker-draggable")},removeHooks:function(){this._draggable.off("dragstart",this._onDragStart,this).off("drag",this._onDrag,this).off("dragend",this._onDragEnd,this),this._draggable.disable(),o.DomUtil.removeClass(this._marker._icon,"leaflet-marker-draggable")},moved:function(){return this._draggable&&this._draggable._moved},_onDragStart:function(){this._marker.closePopup().fire("movestart").fire("dragstart")},_onDrag:function(){var t=this._marker,e=t._shadow,i=o.DomUtil.getPosition(t._icon),n=t._map.layerPointToLatLng(i);e&&o.DomUtil.setPosition(e,i),t._latlng=n,t.fire("move",{latlng:n}).fire("drag")},_onDragEnd:function(t){this._marker.fire("moveend").fire("dragend",t)}}),o.Control=o.Class.extend({options:{position:"topright"},initialize:function(t){o.setOptions(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),n=t._controlCorners[i];return o.DomUtil.addClass(e,"leaflet-control"),-1!==i.indexOf("bottom")?n.insertBefore(e,n.firstChild):n.appendChild(e),this},removeFrom:function(t){var e=this.getPosition(),i=t._controlCorners[e];return i.removeChild(this._container),this._map=null,this.onRemove&&this.onRemove(t),this},_refocusOnMap:function(){this._map&&this._map.getContainer().focus()}}),o.control=function(t){return new o.Control(t)},o.Map.include({addControl:function(t){return t.addTo(this),this},removeControl:function(t){return t.removeFrom(this),this},_initControlPos:function(){function t(t,s){var a=i+t+" "+i+s;e[t+s]=o.DomUtil.create("div",a,n)}var e=this._controlCorners={},i="leaflet-",n=this._controlContainer=o.DomUtil.create("div",i+"control-container",this._container);t("top","left"),t("top","right"),t("bottom","left"),t("bottom","right")},_clearControlPos:function(){this._container.removeChild(this._controlContainer)}}),o.Control.Zoom=o.Control.extend({options:{position:"topleft",zoomInText:"+",zoomInTitle:"Zoom in",zoomOutText:"-",zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=o.DomUtil.create("div",e+" leaflet-bar");return this._map=t,this._zoomInButton=this._createButton(this.options.zoomInText,this.options.zoomInTitle,e+"-in",i,this._zoomIn,this),this._zoomOutButton=this._createButton(this.options.zoomOutText,this.options.zoomOutTitle,e+"-out",i,this._zoomOut,this),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},_zoomIn:function(t){this._map.zoomIn(t.shiftKey?3:1)},_zoomOut:function(t){this._map.zoomOut(t.shiftKey?3:1)},_createButton:function(t,e,i,n,s,a){var r=o.DomUtil.create("a",i,n);r.innerHTML=t,r.href="#",r.title=e;var h=o.DomEvent.stopPropagation;return o.DomEvent.on(r,"click",h).on(r,"mousedown",h).on(r,"dblclick",h).on(r,"click",o.DomEvent.preventDefault).on(r,"click",s,a).on(r,"click",this._refocusOnMap,a),r},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";o.DomUtil.removeClass(this._zoomInButton,e),o.DomUtil.removeClass(this._zoomOutButton,e),t._zoom===t.getMinZoom()&&o.DomUtil.addClass(this._zoomOutButton,e),t._zoom===t.getMaxZoom()&&o.DomUtil.addClass(this._zoomInButton,e)}}),o.Map.mergeOptions({zoomControl:!0}),o.Map.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new o.Control.Zoom,this.addControl(this.zoomControl))}),o.control.zoom=function(t){return new o.Control.Zoom(t)},o.Control.Attribution=o.Control.extend({options:{position:"bottomright",prefix:'Leaflet'},initialize:function(t){o.setOptions(this,t),this._attributions={}},onAdd:function(t){this._container=o.DomUtil.create("div","leaflet-control-attribution"),o.DomEvent.disableClickPropagation(this._container);for(var e in t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return t.on("layeradd",this._onLayerAdd,this).on("layerremove",this._onLayerRemove,this),this._update(),this._container},onRemove:function(t){t.off("layeradd",this._onLayerAdd).off("layerremove",this._onLayerRemove)},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t?(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update(),this):void 0},removeAttribution:function(t){return t?(this._attributions[t]&&(this._attributions[t]--,this._update()),this):void 0},_update:function(){if(this._map){var t=[];for(var e in this._attributions)this._attributions[e]&&t.push(e);var i=[];this.options.prefix&&i.push(this.options.prefix),t.length&&i.push(t.join(", ")),this._container.innerHTML=i.join(" | ")}},_onLayerAdd:function(t){t.layer.getAttribution&&this.addAttribution(t.layer.getAttribution())},_onLayerRemove:function(t){t.layer.getAttribution&&this.removeAttribution(t.layer.getAttribution())}}),o.Map.mergeOptions({attributionControl:!0}),o.Map.addInitHook(function(){this.options.attributionControl&&(this.attributionControl=(new o.Control.Attribution).addTo(this))}),o.control.attribution=function(t){return new o.Control.Attribution(t)},o.Control.Scale=o.Control.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0,updateWhenIdle:!1},onAdd:function(t){this._map=t;var e="leaflet-control-scale",i=o.DomUtil.create("div",e),n=this.options;return this._addScales(n,e,i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=o.DomUtil.create("div",e+"-line",i)),t.imperial&&(this._iScale=o.DomUtil.create("div",e+"-line",i))},_update:function(){var t=this._map.getBounds(),e=t.getCenter().lat,i=6378137*Math.PI*Math.cos(e*Math.PI/180),n=i*(t.getNorthEast().lng-t.getSouthWest().lng)/180,o=this._map.getSize(),s=this.options,a=0;o.x>0&&(a=n*(s.maxWidth/o.x)),this._updateScales(s,a)},_updateScales:function(t,e){t.metric&&e&&this._updateMetric(e),t.imperial&&e&&this._updateImperial(e)},_updateMetric:function(t){var e=this._getRoundNum(t);this._mScale.style.width=this._getScaleWidth(e/t)+"px",this._mScale.innerHTML=1e3>e?e+" m":e/1e3+" km"},_updateImperial:function(t){var e,i,n,o=3.2808399*t,s=this._iScale;o>5280?(e=o/5280,i=this._getRoundNum(e),s.style.width=this._getScaleWidth(i/e)+"px",s.innerHTML=i+" mi"):(n=this._getRoundNum(o),s.style.width=this._getScaleWidth(n/o)+"px",s.innerHTML=n+" ft")},_getScaleWidth:function(t){return Math.round(this.options.maxWidth*t)-10},_getRoundNum:function(t){var e=Math.pow(10,(Math.floor(t)+"").length-1),i=t/e;return i=i>=10?10:i>=5?5:i>=3?3:i>=2?2:1,e*i}}),o.control.scale=function(t){return new o.Control.Scale(t)},o.Control.Layers=o.Control.extend({options:{collapsed:!0,position:"topright",autoZIndex:!0},initialize:function(t,e,i){o.setOptions(this,i),this._layers={},this._lastZIndex=0,this._handlingClick=!1;for(var n in t)this._addLayer(t[n],n);for(n in e)this._addLayer(e[n],n,!0)},onAdd:function(t){return this._initLayout(),this._update(),t.on("layeradd",this._onLayerChange,this).on("layerremove",this._onLayerChange,this),this._container},onRemove:function(t){t.off("layeradd",this._onLayerChange,this).off("layerremove",this._onLayerChange,this)},addBaseLayer:function(t,e){return this._addLayer(t,e),this._update(),this},addOverlay:function(t,e){return this._addLayer(t,e,!0),this._update(),this},removeLayer:function(t){var e=o.stamp(t);return delete this._layers[e],this._update(),this},_initLayout:function(){var t="leaflet-control-layers",e=this._container=o.DomUtil.create("div",t);e.setAttribute("aria-haspopup",!0),o.Browser.touch?o.DomEvent.on(e,"click",o.DomEvent.stopPropagation):o.DomEvent.disableClickPropagation(e).disableScrollPropagation(e);var i=this._form=o.DomUtil.create("form",t+"-list");if(this.options.collapsed){o.Browser.android||o.DomEvent.on(e,"mouseover",this._expand,this).on(e,"mouseout",this._collapse,this);var n=this._layersLink=o.DomUtil.create("a",t+"-toggle",e);n.href="#",n.title="Layers",o.Browser.touch?o.DomEvent.on(n,"click",o.DomEvent.stop).on(n,"click",this._expand,this):o.DomEvent.on(n,"focus",this._expand,this),o.DomEvent.on(i,"click",function(){setTimeout(o.bind(this._onInputClick,this),0)},this),this._map.on("click",this._collapse,this)}else this._expand();this._baseLayersList=o.DomUtil.create("div",t+"-base",i),this._separator=o.DomUtil.create("div",t+"-separator",i),this._overlaysList=o.DomUtil.create("div",t+"-overlays",i),e.appendChild(i)},_addLayer:function(t,e,i){var n=o.stamp(t);this._layers[n]={layer:t,name:e,overlay:i},this.options.autoZIndex&&t.setZIndex&&(this._lastZIndex++,t.setZIndex(this._lastZIndex))},_update:function(){if(this._container){this._baseLayersList.innerHTML="",this._overlaysList.innerHTML="";var t,e,i=!1,n=!1;for(t in this._layers)e=this._layers[t],this._addItem(e),n=n||e.overlay,i=i||!e.overlay;this._separator.style.display=n&&i?"":"none"}},_onLayerChange:function(t){var e=this._layers[o.stamp(t.layer)];if(e){this._handlingClick||this._update();var i=e.overlay?"layeradd"===t.type?"overlayadd":"overlayremove":"layeradd"===t.type?"baselayerchange":null;i&&this._map.fire(i,e)}},_createRadioElement:function(t,i){var n='t;t++)e=n[t],i=this._layers[e.layerId],e.checked&&!this._map.hasLayer(i.layer)?this._map.addLayer(i.layer):!e.checked&&this._map.hasLayer(i.layer)&&this._map.removeLayer(i.layer);this._handlingClick=!1,this._refocusOnMap()},_expand:function(){o.DomUtil.addClass(this._container,"leaflet-control-layers-expanded")},_collapse:function(){this._container.className=this._container.className.replace(" leaflet-control-layers-expanded","")}}),o.control.layers=function(t,e,i){return new o.Control.Layers(t,e,i)},o.PosAnimation=o.Class.extend({includes:o.Mixin.Events,run:function(t,e,i,n){this.stop(),this._el=t,this._inProgress=!0,this._newPos=e,this.fire("start"),t.style[o.DomUtil.TRANSITION]="all "+(i||.25)+"s cubic-bezier(0,0,"+(n||.5)+",1)",o.DomEvent.on(t,o.DomUtil.TRANSITION_END,this._onTransitionEnd,this),o.DomUtil.setPosition(t,e),o.Util.falseFn(t.offsetWidth),this._stepTimer=setInterval(o.bind(this._onStep,this),50)},stop:function(){this._inProgress&&(o.DomUtil.setPosition(this._el,this._getPos()),this._onTransitionEnd(),o.Util.falseFn(this._el.offsetWidth))},_onStep:function(){var t=this._getPos();return t?(this._el._leaflet_pos=t,void this.fire("step")):void this._onTransitionEnd()},_transformRe:/([-+]?(?:\d*\.)?\d+)\D*, ([-+]?(?:\d*\.)?\d+)\D*\)/,_getPos:function(){var e,i,n,s=this._el,a=t.getComputedStyle(s);if(o.Browser.any3d){if(n=a[o.DomUtil.TRANSFORM].match(this._transformRe),!n)return;e=parseFloat(n[1]),i=parseFloat(n[2])}else e=parseFloat(a.left),i=parseFloat(a.top);return new o.Point(e,i,(!0))},_onTransitionEnd:function(){o.DomEvent.off(this._el,o.DomUtil.TRANSITION_END,this._onTransitionEnd,this),this._inProgress&&(this._inProgress=!1,this._el.style[o.DomUtil.TRANSITION]="",this._el._leaflet_pos=this._newPos,clearInterval(this._stepTimer),this.fire("step").fire("end"))}}),o.Map.include({setView:function(t,e,n){if(e=e===i?this._zoom:this._limitZoom(e),t=this._limitCenter(o.latLng(t),e,this.options.maxBounds),n=n||{},this._panAnim&&this._panAnim.stop(),this._loaded&&!n.reset&&n!==!0){n.animate!==i&&(n.zoom=o.extend({animate:n.animate},n.zoom),n.pan=o.extend({animate:n.animate},n.pan));var s=this._zoom!==e?this._tryAnimatedZoom&&this._tryAnimatedZoom(t,e,n.zoom):this._tryAnimatedPan(t,n.pan);if(s)return clearTimeout(this._sizeTimer),this}return this._resetView(t,e),this},panBy:function(t,e){if(t=o.point(t).round(),e=e||{},!t.x&&!t.y)return this;if(this._panAnim||(this._panAnim=new o.PosAnimation,this._panAnim.on({step:this._onPanTransitionStep,end:this._onPanTransitionEnd},this)),e.noMoveStart||this.fire("movestart"),e.animate!==!1){o.DomUtil.addClass(this._mapPane,"leaflet-pan-anim");var i=this._getMapPanePos().subtract(t);this._panAnim.run(this._mapPane,i,e.duration||.25,e.easeLinearity)}else this._rawPanBy(t),this.fire("move").fire("moveend");return this},_onPanTransitionStep:function(){this.fire("move")},_onPanTransitionEnd:function(){o.DomUtil.removeClass(this._mapPane,"leaflet-pan-anim"),this.fire("moveend")},_tryAnimatedPan:function(t,e){var i=this._getCenterOffset(t)._floor();return!((e&&e.animate)!==!0&&!this.getSize().contains(i))&&(this.panBy(i,e),!0)}}),o.PosAnimation=o.DomUtil.TRANSITION?o.PosAnimation:o.PosAnimation.extend({run:function(t,e,i,n){this.stop(),this._el=t,this._inProgress=!0,this._duration=i||.25,this._easeOutPower=1/Math.max(n||.5,.2),this._startPos=o.DomUtil.getPosition(t),this._offset=e.subtract(this._startPos),this._startTime=+new Date,this.fire("start"),this._animate()},stop:function(){this._inProgress&&(this._step(),this._complete())},_animate:function(){this._animId=o.Util.requestAnimFrame(this._animate,this),this._step()},_step:function(){var t=+new Date-this._startTime,e=1e3*this._duration;e>t?this._runFrame(this._easeOut(t/e)):(this._runFrame(1),this._complete())},_runFrame:function(t){var e=this._startPos.add(this._offset.multiplyBy(t));o.DomUtil.setPosition(this._el,e),this.fire("step")},_complete:function(){o.Util.cancelAnimFrame(this._animId),this._inProgress=!1,this.fire("end")},_easeOut:function(t){return 1-Math.pow(1-t,this._easeOutPower)}}),o.Map.mergeOptions({zoomAnimation:!0,zoomAnimationThreshold:4}),o.DomUtil.TRANSITION&&o.Map.addInitHook(function(){this._zoomAnimated=this.options.zoomAnimation&&o.DomUtil.TRANSITION&&o.Browser.any3d&&!o.Browser.android23&&!o.Browser.mobileOpera,this._zoomAnimated&&o.DomEvent.on(this._mapPane,o.DomUtil.TRANSITION_END,this._catchTransitionEnd,this)}),o.Map.include(o.DomUtil.TRANSITION?{_catchTransitionEnd:function(t){this._animatingZoom&&t.propertyName.indexOf("transform")>=0&&this._onZoomTransitionEnd()},_nothingToAnimate:function(){return!this._container.getElementsByClassName("leaflet-zoom-animated").length},_tryAnimatedZoom:function(t,e,i){if(this._animatingZoom)return!0;if(i=i||{},!this._zoomAnimated||i.animate===!1||this._nothingToAnimate()||Math.abs(e-this._zoom)>this.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),o=this._getCenterOffset(t)._divideBy(1-1/n),s=this._getCenterLayerPoint()._add(o);return!(i.animate!==!0&&!this.getSize().contains(o))&&(this.fire("movestart").fire("zoomstart"),this._animateZoom(t,e,s,n,null,!0),!0)},_animateZoom:function(t,e,i,n,s,a,r){r||(this._animatingZoom=!0),o.DomUtil.addClass(this._mapPane,"leaflet-zoom-anim"),this._animateToCenter=t,this._animateToZoom=e,o.Draggable&&(o.Draggable._disabled=!0),o.Util.requestAnimFrame(function(){this.fire("zoomanim",{center:t,zoom:e,origin:i,scale:n,delta:s,backwards:a}),setTimeout(o.bind(this._onZoomTransitionEnd,this),250)},this)},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._animatingZoom=!1,o.DomUtil.removeClass(this._mapPane,"leaflet-zoom-anim"),o.Util.requestAnimFrame(function(){this._resetView(this._animateToCenter,this._animateToZoom,!0,!0),o.Draggable&&(o.Draggable._disabled=!1)},this))}}:{}),o.TileLayer.include({_animateZoom:function(t){this._animating||(this._animating=!0,this._prepareBgBuffer());var e=this._bgBuffer,i=o.DomUtil.TRANSFORM,n=t.delta?o.DomUtil.getTranslateString(t.delta):e.style[i],s=o.DomUtil.getScaleString(t.scale,t.origin);e.style[i]=t.backwards?s+" "+n:n+" "+s},_endZoomAnim:function(){var t=this._tileContainer,e=this._bgBuffer;t.style.visibility="",t.parentNode.appendChild(t),o.Util.falseFn(e.offsetWidth);var i=this._map.getZoom();(i>this.options.maxZoom||i.5&&.5>n?(t.style.visibility="hidden",void this._stopLoadingImages(t)):(e.style.visibility="hidden",e.style[o.DomUtil.TRANSFORM]="",this._tileContainer=e,e=this._bgBuffer=t,this._stopLoadingImages(e),void clearTimeout(this._clearBgBufferTimer))},_getLoadedTilesPercentage:function(t){var e,i,n=t.getElementsByTagName("img"),o=0;for(e=0,i=n.length;i>e;e++)n[e].complete&&o++;return o/i},_stopLoadingImages:function(t){var e,i,n,s=Array.prototype.slice.call(t.getElementsByTagName("img"));for(e=0,i=s.length;i>e;e++)n=s[e],n.complete||(n.onload=o.Util.falseFn,n.onerror=o.Util.falseFn,n.src=o.Util.emptyImageUrl,n.parentNode.removeChild(n))}}),o.Map.include({_defaultLocateOptions:{watch:!1,setView:!1,maxZoom:1/0,timeout:1e4,maximumAge:0,enableHighAccuracy:!1},locate:function(t){if(t=this._locateOptions=o.extend(this._defaultLocateOptions,t),!navigator.geolocation)return this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this;var e=o.bind(this._handleGeolocationResponse,this),i=o.bind(this._handleGeolocationError,this);return t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e=t.code,i=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout");this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+i+"."})},_handleGeolocationResponse:function(t){var e=t.coords.latitude,i=t.coords.longitude,n=new o.LatLng(e,i),s=180*t.coords.accuracy/40075017,a=s/Math.cos(o.LatLng.DEG_TO_RAD*e),r=o.latLngBounds([e-s,i-a],[e+s,i+a]),h=this._locateOptions;if(h.setView){var l=Math.min(this.getBoundsZoom(r),h.maxZoom);this.setView(n,l)}var u={latlng:n,bounds:r,timestamp:t.timestamp};for(var c in t.coords)"number"==typeof t.coords[c]&&(u[c]=t.coords[c]);this.fire("locationfound",u)}})}(window,document)- \ 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 b871669338c..7b1c5b24b82 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 3337f250031..d3a04d4e9b3 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -1 +1 @@ -"use strict";function deleteAllCaches(){return caches.keys().then(function(e){return Promise.all(e.map(function(e){return caches.delete(e)}))})}var PrecacheConfig=[["/","2d9bbabfa2dc5f2a651ff1141d7e306c"],["/frontend/panels/dev-event-20327fbd4fb0370aec9be4db26fd723f.html","a9b6eced242c1934a331c05c30e22148"],["/frontend/panels/dev-info-28e0a19ceb95aa714fd53228d9983a49.html","75862082477c802a12d2bf8705990d85"],["/frontend/panels/dev-service-85fd5b48600418bb5a6187539a623c38.html","353e4d80fedbcde9b51e08a78a9ddb86"],["/frontend/panels/dev-state-25d84d7b7aea779bb3bb3cd6c155f8d9.html","7fc5b1880ba4a9d6e97238e8e5a44d69"],["/frontend/panels/dev-template-d079abf61cff9690f828cafb0d29b7e7.html","6e512a2ba0eb7aeba956ca51048e701e"],["/frontend/panels/map-dfe141a3fa5fd403be554def1dd039a9.html","f061ec88561705f7787a00289450c006"],["/static/core-bc78f21f5280217aa2c78dfc5848134f.js","a09b7ee4108fae1f93c10e14a4bfd675"],["/static/frontend-6c52e8cb797bafa3124d936af5ce1fcc.html","a460549fe50b2e7c9cadd94d682c9ed7"],["/static/mdi-f6c6cc64c2ec38a80e91f801b41119b3.html","e010f32322ed6f66916c7c09dbba4acd"],["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"],["static/webcomponents-lite.min.js","b0f32ad3c7749c40d486603f31c9d8b1"]],CacheNamePrefix="sw-precache-v1--"+(self.registration?self.registration.scope:"")+"-",IgnoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var a=new URL(e);return"/"===a.pathname.slice(-1)&&(a.pathname+=t),a.toString()},getCacheBustedUrl=function(e,t){t=t||Date.now();var a=new URL(e);return a.search+=(a.search?"&":"")+"sw-precache="+t,a.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var a=new URL(t).pathname;return e.some(function(e){return a.match(e)})},populateCurrentCacheNames=function(e,t,a){var n={},c={};return e.forEach(function(e){var r=new URL(e[0],a).toString(),o=t+r+"-"+e[1];c[o]=r,n[r]=o}),{absoluteUrlToCacheName:n,currentCacheNamesToAbsoluteUrl:c}},stripIgnoredUrlParameters=function(e,t){var a=new URL(e);return a.search=a.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(e){return t.every(function(t){return!t.test(e[0])})}).map(function(e){return e.join("=")}).join("&"),a.toString()},mappings=populateCurrentCacheNames(PrecacheConfig,CacheNamePrefix,self.location),AbsoluteUrlToCacheName=mappings.absoluteUrlToCacheName,CurrentCacheNamesToAbsoluteUrl=mappings.currentCacheNamesToAbsoluteUrl;self.addEventListener("install",function(e){e.waitUntil(Promise.all(Object.keys(CurrentCacheNamesToAbsoluteUrl).map(function(e){return caches.open(e).then(function(t){return t.keys().then(function(a){if(0===a.length){var n=e.split("-").pop(),c=getCacheBustedUrl(CurrentCacheNamesToAbsoluteUrl[e],n),r=new Request(c,{credentials:"same-origin"});return fetch(r).then(function(a){return a.ok?t.put(CurrentCacheNamesToAbsoluteUrl[e],a):(console.error("Request for %s returned a response status %d, so not attempting to cache it.",c,a.status),caches.delete(e))})}})})})).then(function(){return caches.keys().then(function(e){return Promise.all(e.filter(function(e){return 0===e.indexOf(CacheNamePrefix)&&!(e in CurrentCacheNamesToAbsoluteUrl)}).map(function(e){return caches.delete(e)}))})}).then(function(){"function"==typeof self.skipWaiting&&self.skipWaiting()}))}),self.clients&&"function"==typeof self.clients.claim&&self.addEventListener("activate",function(e){e.waitUntil(self.clients.claim())}),self.addEventListener("message",function(e){"delete_all"===e.data.command&&(console.log("About to delete all caches..."),deleteAllCaches().then(function(){console.log("Caches deleted."),e.ports[0].postMessage({error:null})}).catch(function(t){console.log("Caches not deleted:",t),e.ports[0].postMessage({error:t})}))}),self.addEventListener("fetch",function(e){if("GET"===e.request.method){var t=stripIgnoredUrlParameters(e.request.url,IgnoreUrlParametersMatching),a=AbsoluteUrlToCacheName[t],n="index.html";!a&&n&&(t=addDirectoryIndex(t,n),a=AbsoluteUrlToCacheName[t]);var c="/";if(!a&&c&&e.request.headers.has("accept")&&e.request.headers.get("accept").includes("text/html")&&isPathWhitelisted(["^((?!(static|api|local|service_worker.js)).)*$"],e.request.url)){var r=new URL(c,self.location);a=AbsoluteUrlToCacheName[r.toString()]}a&&e.respondWith(caches.open(a).then(function(e){return e.keys().then(function(t){return e.match(t[0]).then(function(e){if(e)return e;throw Error("The cache "+a+" is empty.")})})}).catch(function(t){return console.warn('Couldn\'t serve response for "%s" from cache: %O',e.request.url,t),fetch(e.request)}))}}); \ No newline at end of file +"use strict";function setOfCachedUrls(e){return e.keys().then(function(e){return e.map(function(e){return e.url})}).then(function(e){return new Set(e)})}var precacheConfig=[["/","a463cb982f337e09c3ed47c41b2d9dda"],["/frontend/panels/dev-event-3cc881ae8026c0fba5aa67d334a3ab2b.html","e22ed0d2d10777c87eb9620d81f525b4"],["/frontend/panels/dev-info-34e2df1af32e60fffcafe7e008a92169.html","7e939dc762dc0c0ec769db4ea76a4b09"],["/frontend/panels/dev-service-bb5c587ada694e0fd42ceaaedd6fe6aa.html","782c4860c5e8ab274231ba9dfd528f29"],["/frontend/panels/dev-state-4608326978256644c42b13940c028e0a.html","26758b741ac1b7c8e9cfcb24762d8774"],["/frontend/panels/dev-template-0a099d4589636ed3038a3e9f020468a7.html","99114026cf9193263c74cc25f9f6a469"],["/frontend/panels/map-af7d04aff7dd5479c5a0016bc8d4dd7d.html","6031df1b4d23d5b321208449b2d293f8"],["/static/core-457d5acd123e7dc38947c07984b3a5e8.js","69e2a5b421d7ed7a7e70390cd9ced80e"],["/static/frontend-829ee7cb591b8a63d7f22948a7aeb07a.html","2afa980f1c1fdf9e596580112ac8e51a"],["/static/mdi-b399b5d3798f5b68b0a4fbaae3432d48.html","819d479ae2b690589687469045b22c26"],["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"],["static/webcomponents-lite.min.js","b0f32ad3c7749c40d486603f31c9d8b1"]],cacheName="sw-precache-v2--"+(self.registration?self.registration.scope:""),ignoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var a=new URL(e);return"/"===a.pathname.slice(-1)&&(a.pathname+=t),a.toString()},createCacheKey=function(e,t,a,n){var c=new URL(e);return n&&c.toString().match(n)||(c.search+=(c.search?"&":"")+encodeURIComponent(t)+"="+encodeURIComponent(a)),c.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var a=new URL(t).pathname;return e.some(function(e){return a.match(e)})},stripIgnoredUrlParameters=function(e,t){var a=new URL(e);return a.search=a.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(e){return t.every(function(t){return!t.test(e[0])})}).map(function(e){return e.join("=")}).join("&"),a.toString()},hashParamName="_sw-precache",urlsToCacheKeys=new Map(precacheConfig.map(function(e){var t=e[0],a=e[1],n=new URL(t,self.location),c=createCacheKey(n,hashParamName,a,!1);return[n.toString(),c]}));self.addEventListener("install",function(e){e.waitUntil(caches.open(cacheName).then(function(e){return setOfCachedUrls(e).then(function(t){return Promise.all(Array.from(urlsToCacheKeys.values()).map(function(a){if(!t.has(a))return e.add(new Request(a,{credentials:"same-origin"}))}))})}).then(function(){return self.skipWaiting()}))}),self.addEventListener("activate",function(e){var t=new Set(urlsToCacheKeys.values());e.waitUntil(caches.open(cacheName).then(function(e){return e.keys().then(function(a){return Promise.all(a.map(function(a){if(!t.has(a.url))return e.delete(a)}))})}).then(function(){return self.clients.claim()}))}),self.addEventListener("fetch",function(e){if("GET"===e.request.method){var t,a=stripIgnoredUrlParameters(e.request.url,ignoreUrlParametersMatching);t=urlsToCacheKeys.has(a);var n="index.html";!t&&n&&(a=addDirectoryIndex(a,n),t=urlsToCacheKeys.has(a));var c="/";!t&&c&&"navigate"===e.request.mode&&isPathWhitelisted(["^((?!(static|api|local|service_worker.js)).)*$"],e.request.url)&&(a=new URL(c,self.location).toString(),t=urlsToCacheKeys.has(a)),t&&e.respondWith(caches.open(cacheName).then(function(e){return e.match(urlsToCacheKeys.get(a))}).catch(function(t){return console.warn('Couldn\'t serve response for "%s" from cache: %O',e.request.url,t),fetch(e.request)}))}}); \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/service_worker.js.gz b/homeassistant/components/frontend/www_static/service_worker.js.gz index e01b7e8cffe..8eac24c6ba9 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/frontend/www_static/webcomponents-lite.min.js.gz b/homeassistant/components/frontend/www_static/webcomponents-lite.min.js.gz index 5d805f47bd3..7975cba5e1c 100644 Binary files a/homeassistant/components/frontend/www_static/webcomponents-lite.min.js.gz and b/homeassistant/components/frontend/www_static/webcomponents-lite.min.js.gz differ diff --git a/homeassistant/components/garage_door/rpi_gpio.py b/homeassistant/components/garage_door/rpi_gpio.py index 536e3177dc4..55ade8c9fc0 100644 --- a/homeassistant/components/garage_door/rpi_gpio.py +++ b/homeassistant/components/garage_door/rpi_gpio.py @@ -72,7 +72,7 @@ class RPiGPIOGarageDoor(GarageDoorDevice): def update(self): """Update the state of the garage door.""" - self._state = rpi_gpio.read_input(self._state_pin) is True + self._state = rpi_gpio.read_input(self._state_pin) @property def is_closed(self): diff --git a/homeassistant/components/garage_door/services.yaml b/homeassistant/components/garage_door/services.yaml index e69de29bb2d..a73c05ce24e 100644 --- a/homeassistant/components/garage_door/services.yaml +++ b/homeassistant/components/garage_door/services.yaml @@ -0,0 +1,15 @@ +open: + description: Open all or specified garage door + + fields: + entity_id: + description: Name(s) of garage door(s) to open + example: 'garage.main' + +close: + description: Close all or a specified garage door + + fields: + entity_id: + description: Name(s) of garage door(s) to close + example: 'garage.main' diff --git a/homeassistant/components/garage_door/zwave.py b/homeassistant/components/garage_door/zwave.py index e8aacf0b6ad..8ef0fcbed63 100644 --- a/homeassistant/components/garage_door/zwave.py +++ b/homeassistant/components/garage_door/zwave.py @@ -13,7 +13,7 @@ from homeassistant.components import zwave from homeassistant.components.garage_door import GarageDoorDevice COMMAND_CLASS_SWITCH_BINARY = 0x25 # 37 - +COMMAND_CLASS_BARRIER_OPERATOR = 0x66 # 102 _LOGGER = logging.getLogger(__name__) @@ -25,7 +25,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): node = zwave.NETWORK.nodes[discovery_info[zwave.ATTR_NODE_ID]] value = node.values[discovery_info[zwave.ATTR_VALUE_ID]] - if value.command_class != zwave.COMMAND_CLASS_SWITCH_BINARY: + if value.command_class != zwave.COMMAND_CLASS_SWITCH_BINARY and \ + value.command_class != zwave.COMMAND_CLASS_BARRIER_OPERATOR: return if value.type != zwave.TYPE_BOOL: return @@ -62,8 +63,8 @@ class ZwaveGarageDoor(zwave.ZWaveDeviceEntity, GarageDoorDevice): def close_door(self): """Close the garage door.""" - self._value.node.set_switch(self._value.value_id, False) + self._value.data = False def open_door(self): """Open the garage door.""" - self._value.node.set_switch(self._value.value_id, True) + self._value.data = True diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py index 9e0c0f897e5..be998b48f23 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -14,8 +14,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ICON, CONF_NAME, STATE_CLOSED, STATE_HOME, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN, ATTR_ASSUMED_STATE) -from homeassistant.helpers.entity import ( - Entity, generate_entity_id, split_entity_id) +from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.helpers.event import track_state_change import homeassistant.helpers.config_validation as cv @@ -101,7 +100,7 @@ def expand_entity_ids(hass, entity_ids): try: # If entity_id points at a group, expand it - domain, _ = split_entity_id(entity_id) + domain, _ = ha.split_entity_id(entity_id) if domain == DOMAIN: found_ids.extend( diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic.py index 94cad95a3cf..fb3316c9bd6 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic.py @@ -17,7 +17,7 @@ from homeassistant.helpers import discovery from homeassistant.config import load_yaml_config_file DOMAIN = 'homematic' -REQUIREMENTS = ["pyhomematic==0.1.10"] +REQUIREMENTS = ["pyhomematic==0.1.11"] HOMEMATIC = None HOMEMATIC_LINK_DELAY = 0.5 diff --git a/homeassistant/components/http.py b/homeassistant/components/http.py index bce4336b609..b9a81858d39 100644 --- a/homeassistant/components/http.py +++ b/homeassistant/components/http.py @@ -20,12 +20,12 @@ from homeassistant.const import ( HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS, ALLOWED_CORS_HEADERS, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) -from homeassistant.helpers.entity import split_entity_id +from homeassistant.core import split_entity_id import homeassistant.util.dt as dt_util import homeassistant.helpers.config_validation as cv DOMAIN = "http" -REQUIREMENTS = ("cherrypy==6.1.1", "static3==0.7.0", "Werkzeug==0.11.10") +REQUIREMENTS = ("cherrypy==7.1.0", "static3==0.7.0", "Werkzeug==0.11.10") CONF_API_PASSWORD = "api_password" CONF_SERVER_HOST = "server_host" @@ -453,6 +453,10 @@ class HomeAssistantView(object): """Handle request to url.""" from werkzeug.exceptions import MethodNotAllowed, Unauthorized + if request.method == "OPTIONS": + # For CORS preflight requests. + return self.options(request) + try: handler = getattr(self, request.method.lower()) except AttributeError: @@ -473,16 +477,16 @@ class HomeAssistantView(object): self.hass.wsgi.api_password): authenticated = True - if authenticated: - _LOGGER.info('Successful login/request from %s', - request.remote_addr) - elif self.requires_auth and not authenticated: + if self.requires_auth and not authenticated: _LOGGER.warning('Login attempt or request with an invalid' 'password from %s', request.remote_addr) raise Unauthorized() request.authenticated = authenticated + _LOGGER.info('Serving %s to %s (auth: %s)', + request.path, request.remote_addr, authenticated) + result = handler(request, **values) if isinstance(result, self.Response): diff --git a/homeassistant/components/hvac/__init__.py b/homeassistant/components/hvac/__init__.py index 560f3d13fd6..abd40a3ac93 100644 --- a/homeassistant/components/hvac/__init__.py +++ b/homeassistant/components/hvac/__init__.py @@ -6,17 +6,18 @@ https://home-assistant.io/components/hvac/ """ import logging import os +from numbers import Number from homeassistant.helpers.entity_component import EntityComponent from homeassistant.config import load_yaml_config_file import homeassistant.util as util +from homeassistant.util.temperature import convert as convert_temperature from homeassistant.helpers.entity import Entity -from homeassistant.helpers.temperature import convert from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_ON, STATE_OFF, STATE_UNKNOWN, - TEMP_CELCIUS) + TEMP_CELSIUS) DOMAIN = "hvac" @@ -204,8 +205,8 @@ def setup(hass, config): return for hvac in target_hvacs: - hvac.set_temperature(convert( - temperature, hass.config.temperature_unit, + hvac.set_temperature(convert_temperature( + temperature, hass.config.units.temperature_unit, hvac.unit_of_measurement)) if hvac.should_poll: @@ -462,12 +463,12 @@ class HvacDevice(Entity): @property def min_temp(self): """Return the minimum temperature.""" - return convert(19, TEMP_CELCIUS, self.unit_of_measurement) + return convert_temperature(19, TEMP_CELSIUS, self.unit_of_measurement) @property def max_temp(self): """Return the maximum temperature.""" - return convert(30, TEMP_CELCIUS, self.unit_of_measurement) + return convert_temperature(30, TEMP_CELSIUS, self.unit_of_measurement) @property def min_humidity(self): @@ -481,13 +482,13 @@ class HvacDevice(Entity): def _convert_for_display(self, temp): """Convert temperature into preferred units for display purposes.""" - if temp is None: - return None + if temp is None or not isinstance(temp, Number): + return temp - value = convert(temp, self.unit_of_measurement, - self.hass.config.temperature_unit) + value = convert_temperature(temp, self.unit_of_measurement, + self.hass.config.units.temperature_unit) - if self.hass.config.temperature_unit is TEMP_CELCIUS: + if self.hass.config.units.temperature_unit is TEMP_CELSIUS: decimal_count = 1 else: # Users of fahrenheit generally expect integer units. diff --git a/homeassistant/components/influxdb.py b/homeassistant/components/influxdb.py index 2070a52085d..ca2ba5d2bfb 100644 --- a/homeassistant/components/influxdb.py +++ b/homeassistant/components/influxdb.py @@ -33,6 +33,7 @@ CONF_PASSWORD = 'password' CONF_SSL = 'ssl' CONF_VERIFY_SSL = 'verify_ssl' CONF_BLACKLIST = 'blacklist' +CONF_WHITELIST = 'whitelist' CONF_TAGS = 'tags' @@ -57,6 +58,7 @@ def setup(hass, config): verify_ssl = util.convert(conf.get(CONF_VERIFY_SSL), bool, DEFAULT_VERIFY_SSL) blacklist = conf.get(CONF_BLACKLIST, []) + whitelist = conf.get(CONF_WHITELIST, []) tags = conf.get(CONF_TAGS, {}) try: @@ -79,6 +81,9 @@ def setup(hass, config): return try: + if len(whitelist) > 0 and state.entity_id not in whitelist: + return + _state = state_helper.state_as_number(state) except ValueError: _state = state.state diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index 27b2ca2249e..ed696b0654e 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -9,11 +9,12 @@ import logging import socket import voluptuous as vol -from homeassistant.components.light import Light +from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_RGB_COLOR, + Light) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['https://github.com/Danielhiversen/flux_led/archive/0.3.zip' - '#flux_led==0.3'] +REQUIREMENTS = ['https://github.com/Danielhiversen/flux_led/archive/0.6.zip' + '#flux_led==0.6'] _LOGGER = logging.getLogger(__name__) DOMAIN = "flux_led" @@ -37,7 +38,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): light_ips = [] for ipaddr, device_config in config["devices"].items(): device = {} - device['id'] = device_config[ATTR_NAME] + device['name'] = device_config[ATTR_NAME] device['ipaddr'] = ipaddr light = FluxLight(device) if light.is_valid: @@ -50,11 +51,14 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): # Find the bulbs on the LAN scanner = flux_led.BulbScanner() - scanner.scan(timeout=20) + scanner.scan(timeout=10) for device in scanner.getBulbInfo(): - light = FluxLight(device) ipaddr = device['ipaddr'] - if light.is_valid and ipaddr not in light_ips: + if ipaddr in light_ips: + continue + device['name'] = device['id'] + " " + ipaddr + light = FluxLight(device) + if light.is_valid: lights.append(light) light_ips.append(ipaddr) @@ -69,7 +73,7 @@ class FluxLight(Light): """Initialize the light.""" import flux_led - self._name = device['id'] + self._name = device['name'] self._ipaddr = device['ipaddr'] self.is_valid = True self._bulb = None @@ -96,9 +100,27 @@ class FluxLight(Light): """Return true if device is on.""" return self._bulb.isOn() + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._bulb.getWarmWhite255() + + @property + def rgb_color(self): + """Return the color property.""" + return self._bulb.getRgb() + def turn_on(self, **kwargs): """Turn the specified or all lights on.""" - self._bulb.turnOn() + if not self.is_on: + self._bulb.turnOn() + + rgb = kwargs.get(ATTR_RGB_COLOR) + brightness = kwargs.get(ATTR_BRIGHTNESS) + if rgb: + self._bulb.setRgb(*tuple(rgb)) + elif brightness: + self._bulb.setWarmWhite255(brightness) def turn_off(self, **kwargs): """Turn the specified or all lights off.""" diff --git a/homeassistant/components/light/hyperion.py b/homeassistant/components/light/hyperion.py index 7007f3dec34..8a03048d0bc 100644 --- a/homeassistant/components/light/hyperion.py +++ b/homeassistant/components/light/hyperion.py @@ -72,6 +72,11 @@ class Hyperion(Light): """Get the remote's active color.""" response = self.json_request({"command": "serverinfo"}) if response: + # workaround for outdated Hyperion + if "activeLedColor" not in response["info"]: + self._rgb_color = self._default_color + return + if response["info"]["activeLedColor"] == []: self._rgb_color = [0, 0, 0] else: diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index a4d543d38c8..e5b749037ad 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -4,8 +4,6 @@ Support for the LIFX platform that implements lights. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.lifx/ """ -# pylint: disable=missing-docstring - import colorsys import logging @@ -16,7 +14,6 @@ from homeassistant.helpers.event import track_time_change _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['liffylights==0.9.4'] -DEPENDENCIES = [] CONF_SERVER = "server" # server address configuration item CONF_BROADCAST = "broadcast" # broadcast address configuration item @@ -94,11 +91,11 @@ class LIFX(): # pylint: disable=unused-argument def poll(self, now): - """Initialize the light.""" + """Polling for the light.""" self.probe() def probe(self, address=None): - """Initialize the light.""" + """Probe the light.""" self._liffylights.probe(address) diff --git a/homeassistant/components/light/mqtt_json.py b/homeassistant/components/light/mqtt_json.py new file mode 100755 index 00000000000..76db3fe9f0c --- /dev/null +++ b/homeassistant/components/light/mqtt_json.py @@ -0,0 +1,233 @@ +""" +Support for MQTT JSON lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.mqtt_json/ +""" + +import logging +import json +import voluptuous as vol + +import homeassistant.components.mqtt as mqtt +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_TRANSITION, + ATTR_FLASH, FLASH_LONG, FLASH_SHORT, Light) +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_PLATFORM +from homeassistant.components.mqtt import ( + CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "mqtt_json" + +DEPENDENCIES = ["mqtt"] + +DEFAULT_NAME = "MQTT JSON Light" +DEFAULT_OPTIMISTIC = False +DEFAULT_BRIGHTNESS = False +DEFAULT_RGB = False +DEFAULT_FLASH_TIME_SHORT = 2 +DEFAULT_FLASH_TIME_LONG = 10 + +CONF_BRIGHTNESS = "brightness" +CONF_RGB = "rgb" +CONF_FLASH_TIME_SHORT = "flash_time_short" +CONF_FLASH_TIME_LONG = "flash_time_long" + +# Stealing some of these from the base MQTT configs. +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): DOMAIN, + vol.Optional(CONF_QOS, default=mqtt.DEFAULT_QOS): + vol.All(vol.Coerce(int), vol.In([0, 1, 2])), + vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean, + vol.Optional(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_BRIGHTNESS, default=DEFAULT_BRIGHTNESS): cv.boolean, + vol.Optional(CONF_RGB, default=DEFAULT_RGB): cv.boolean, + vol.Optional(CONF_FLASH_TIME_SHORT, default=DEFAULT_FLASH_TIME_SHORT): + cv.positive_int, + vol.Optional(CONF_FLASH_TIME_LONG, default=DEFAULT_FLASH_TIME_LONG): + cv.positive_int +}) + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup a MQTT JSON Light.""" + add_devices_callback([MqttJson( + hass, + config[CONF_NAME], + { + key: config.get(key) for key in ( + CONF_STATE_TOPIC, + CONF_COMMAND_TOPIC + ) + }, + config[CONF_QOS], + config[CONF_RETAIN], + config[CONF_OPTIMISTIC], + config[CONF_BRIGHTNESS], + config[CONF_RGB], + { + key: config.get(key) for key in ( + CONF_FLASH_TIME_SHORT, + CONF_FLASH_TIME_LONG + ) + } + )]) + + +class MqttJson(Light): + """Representation of a MQTT JSON light.""" + + # pylint: disable=too-many-arguments,too-many-instance-attributes + def __init__(self, hass, name, topic, qos, retain, + optimistic, brightness, rgb, flash_times): + """Initialize MQTT JSON light.""" + self._hass = hass + self._name = name + self._topic = topic + self._qos = qos + self._retain = retain + self._optimistic = optimistic or topic["state_topic"] is None + self._state = False + if brightness: + self._brightness = 255 + else: + self._brightness = None + + if rgb: + self._rgb = [0, 0, 0] + else: + self._rgb = None + + self._flash_times = flash_times + + def state_received(topic, payload, qos): + """A new MQTT message has been received.""" + values = json.loads(payload) + + if values["state"] == "ON": + self._state = True + elif values["state"] == "OFF": + self._state = False + + if self._rgb is not None: + try: + red = int(values["color"]["r"]) + green = int(values["color"]["g"]) + blue = int(values["color"]["b"]) + + self._rgb = [red, green, blue] + except KeyError: + pass + except ValueError: + _LOGGER.warning("Invalid color value received.") + + if self._brightness is not None: + try: + self._brightness = int(values["brightness"]) + except KeyError: + pass + except ValueError: + _LOGGER.warning("Invalid brightness value received.") + + self.update_ha_state() + + if self._topic["state_topic"] is not None: + mqtt.subscribe(self._hass, self._topic["state_topic"], + state_received, self._qos) + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def rgb_color(self): + """Return the RGB color value.""" + return self._rgb + + @property + def should_poll(self): + """No polling needed for a MQTT light.""" + return False + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def assumed_state(self): + """Return true if we do optimistic updates.""" + return self._optimistic + + def turn_on(self, **kwargs): + """Turn the device on.""" + should_update = False + + message = {"state": "ON"} + + if ATTR_RGB_COLOR in kwargs: + message["color"] = { + "r": kwargs[ATTR_RGB_COLOR][0], + "g": kwargs[ATTR_RGB_COLOR][1], + "b": kwargs[ATTR_RGB_COLOR][2] + } + + if self._optimistic: + self._rgb = kwargs[ATTR_RGB_COLOR] + should_update = True + + if ATTR_FLASH in kwargs: + flash = kwargs.get(ATTR_FLASH) + + if flash == FLASH_LONG: + message["flash"] = self._flash_times[CONF_FLASH_TIME_LONG] + elif flash == FLASH_SHORT: + message["flash"] = self._flash_times[CONF_FLASH_TIME_SHORT] + + if ATTR_TRANSITION in kwargs: + message["transition"] = kwargs[ATTR_TRANSITION] + + if ATTR_BRIGHTNESS in kwargs: + message["brightness"] = int(kwargs[ATTR_BRIGHTNESS]) + + if self._optimistic: + self._brightness = kwargs[ATTR_BRIGHTNESS] + should_update = True + + mqtt.publish(self._hass, self._topic["command_topic"], + json.dumps(message), self._qos, self._retain) + + if self._optimistic: + # Optimistically assume that the light has changed state. + self._state = True + should_update = True + + if should_update: + self.update_ha_state() + + def turn_off(self, **kwargs): + """Turn the device off.""" + message = {"state": "OFF"} + + if ATTR_TRANSITION in kwargs: + message["transition"] = kwargs[ATTR_TRANSITION] + + mqtt.publish(self._hass, self._topic["command_topic"], + json.dumps(message), self._qos, self._retain) + + if self._optimistic: + # Optimistically assume that the light has changed state. + self._state = False + self.update_ha_state() diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 1986da20e94..95db9d2b33a 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -22,6 +22,7 @@ from homeassistant.components import group DOMAIN = 'lock' SCAN_INTERVAL = 30 +ATTR_CHANGED_BY = 'changed_by' GROUP_NAME_ALL_LOCKS = 'all locks' ENTITY_ID_ALL_LOCKS = group.ENTITY_ID_FORMAT.format('all_locks') @@ -101,6 +102,11 @@ def setup(hass, config): class LockDevice(Entity): """Representation of a lock.""" + @property + def changed_by(self): + """Last change triggered by.""" + return None + # pylint: disable=no-self-use @property def code_format(self): @@ -127,6 +133,7 @@ class LockDevice(Entity): return None state_attr = { ATTR_CODE_FORMAT: self.code_format, + ATTR_CHANGED_BY: self.changed_by } return state_attr diff --git a/homeassistant/components/lock/services.yaml b/homeassistant/components/lock/services.yaml index e69de29bb2d..40a7c3ffe38 100644 --- a/homeassistant/components/lock/services.yaml +++ b/homeassistant/components/lock/services.yaml @@ -0,0 +1,21 @@ +lock: + description: Lock all or specified locks + + fields: + entity_id: + description: Name of lock to lock + example: 'lock.front_door' + code: + description: An optional code to lock the lock with + example: 1234 + +unlock: + description: Unlock all or specified locks + + fields: + entity_id: + description: Name of lock to unlock + example: 'lock.front_door' + code: + description: An optional code to unlock the lock with + example: 1234 diff --git a/homeassistant/components/lock/verisure.py b/homeassistant/components/lock/verisure.py index c9f18c5533e..fe7a9eeaf5a 100644 --- a/homeassistant/components/lock/verisure.py +++ b/homeassistant/components/lock/verisure.py @@ -35,6 +35,7 @@ class VerisureDoorlock(LockDevice): self._id = device_id self._state = STATE_UNKNOWN self._digits = int(hub.config.get('code_digits', '4')) + self._changed_by = None @property def name(self): @@ -51,6 +52,11 @@ class VerisureDoorlock(LockDevice): """Return True if entity is available.""" return hub.available + @property + def changed_by(self): + """Last change triggered by.""" + return self._changed_by + @property def code_format(self): """Return the required six digit code.""" @@ -68,6 +74,7 @@ class VerisureDoorlock(LockDevice): _LOGGER.error( 'Unknown lock state %s', hub.lock_status[self._id].status) + self._changed_by = hub.lock_status[self._id].name @property def is_locked(self): diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 6508318a907..0e8e1cdf1bd 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -18,10 +18,8 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.const import (EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, STATE_NOT_HOME, STATE_OFF, STATE_ON) -from homeassistant.core import DOMAIN as HA_DOMAIN -from homeassistant.core import State +from homeassistant.core import State, split_entity_id, DOMAIN as HA_DOMAIN from homeassistant.helpers import template -from homeassistant.helpers.entity import split_entity_id DOMAIN = "logbook" DEPENDENCIES = ['recorder', 'frontend'] @@ -196,6 +194,11 @@ def humanify(events): event != last_sensor_event[to_state.entity_id]: continue + # Don't show continuous sensor value changes in the logbook + if domain == 'sensor' and \ + to_state.attributes.get('unit_of_measurement'): + continue + yield Entry( event.time_fired, name=to_state.name, diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 57167317553..7efbbe01653 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/media_player/ """ import logging import os +import requests import voluptuous as vol @@ -13,6 +14,7 @@ from homeassistant.config import load_yaml_config_file from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa +from homeassistant.components.http import HomeAssistantView import homeassistant.helpers.config_validation as cv from homeassistant.const import ( STATE_OFF, STATE_UNKNOWN, STATE_PLAYING, STATE_IDLE, @@ -25,10 +27,13 @@ from homeassistant.const import ( _LOGGER = logging.getLogger(__name__) DOMAIN = 'media_player' +DEPENDENCIES = ['http'] SCAN_INTERVAL = 10 ENTITY_ID_FORMAT = DOMAIN + '.{}' +ENTITY_IMAGE_URL = '/api/media_player_proxy/{0}?token={1}' + SERVICE_PLAY_MEDIA = 'play_media' SERVICE_SELECT_SOURCE = 'select_source' SERVICE_CLEAR_PLAYLIST = 'clear_playlist' @@ -286,6 +291,8 @@ def setup(hass, config): component = EntityComponent( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) + hass.wsgi.register_view(MediaPlayerImageView(hass, component.entities)) + component.setup(config) descriptions = load_yaml_config_file( @@ -398,6 +405,11 @@ class MediaPlayerDevice(Entity): """State of the player.""" return STATE_UNKNOWN + @property + def access_token(self): + """Access token for this media player.""" + return str(id(self)) + @property def volume_level(self): """Volume level of the media player (0..1).""" @@ -633,7 +645,8 @@ class MediaPlayerDevice(Entity): @property def entity_picture(self): """Return image of the media playing.""" - return None if self.state == STATE_OFF else self.media_image_url + return None if self.state == STATE_OFF else \ + ENTITY_IMAGE_URL.format(self.entity_id, self.access_token) @property def state_attributes(self): @@ -649,3 +662,39 @@ class MediaPlayerDevice(Entity): } return state_attr + + +class MediaPlayerImageView(HomeAssistantView): + """Media player view to serve an image.""" + + url = "/api/media_player_proxy/" + name = "api:media_player:image" + + def __init__(self, hass, entities): + """Initialize a media player view.""" + super().__init__(hass) + self.entities = entities + + def get(self, request, entity_id): + """Start a get request.""" + player = self.entities.get(entity_id) + + if player is None: + return self.Response(status=404) + + authenticated = (request.authenticated or + request.args.get('token') == player.access_token) + + if not authenticated: + return self.Response(status=401) + + image_url = player.media_image_url + if image_url: + response = requests.get(image_url) + else: + response = None + + if response is None: + return self.Response(status=500) + + return self.Response(response) diff --git a/homeassistant/components/media_player/braviatv.py b/homeassistant/components/media_player/braviatv.py index 14b0b4c2327..3e9e8fdbd44 100644 --- a/homeassistant/components/media_player/braviatv.py +++ b/homeassistant/components/media_player/braviatv.py @@ -41,9 +41,12 @@ def _get_mac_address(ip_address): pid = Popen(["arp", "-n", ip_address], stdout=PIPE) pid_component = pid.communicate()[0] - mac = re.search(r"(([a-f\d]{1,2}\:){5}[a-f\d]{1,2})".encode('UTF-8'), - pid_component).groups()[0] - return mac + match = re.search(r"(([a-f\d]{1,2}\:){5}[a-f\d]{1,2})".encode('UTF-8'), + pid_component) + if match is not None: + return match.groups()[0] + else: + return None def _config_from_file(filename, config=None): diff --git a/homeassistant/components/media_player/lg_netcast.py b/homeassistant/components/media_player/lg_netcast.py index 7f15962723b..fc0609a7c34 100644 --- a/homeassistant/components/media_player/lg_netcast.py +++ b/homeassistant/components/media_player/lg_netcast.py @@ -163,6 +163,11 @@ class LgTVDevice(MediaPlayerDevice): """Flag of media commands that are supported.""" return SUPPORT_LGTV + @property + def media_image_url(self): + """URL for obtaining a screen capture.""" + return self._client.url + 'data?target=screen_image' + def turn_off(self): """Turn off media player.""" self.send_command(1) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 3db8e5ace29..29d332646bb 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -12,15 +12,16 @@ from urllib.parse import urlparse import homeassistant.util as util from homeassistant.components.media_player import ( - MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, - SUPPORT_PREVIOUS_TRACK, MediaPlayerDevice) + MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, + SUPPORT_PREVIOUS_TRACK, SUPPORT_PAUSE, SUPPORT_STOP, SUPPORT_VOLUME_SET, + MediaPlayerDevice) from homeassistant.const import ( DEVICE_DEFAULT_NAME, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN) from homeassistant.loader import get_component from homeassistant.helpers.event import (track_utc_time_change) -REQUIREMENTS = ['plexapi==1.1.0'] +REQUIREMENTS = ['plexapi==2.0.2'] MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) @@ -30,7 +31,8 @@ PLEX_CONFIG_FILE = 'plex.conf' _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) -SUPPORT_PLEX = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK +SUPPORT_PLEX = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ + SUPPORT_STOP | SUPPORT_VOLUME_SET def config_from_file(filename, config=None): @@ -193,6 +195,9 @@ class PlexClient(MediaPlayerDevice): # pylint: disable=too-many-public-methods, attribute-defined-outside-init def __init__(self, device, plex_sessions, update_devices, update_sessions): """Initialize the Plex device.""" + from plexapi.utils import NA + + self.na_type = NA self.plex_sessions = plex_sessions self.update_devices = update_devices self.update_sessions = update_sessions @@ -211,20 +216,17 @@ class PlexClient(MediaPlayerDevice): @property def name(self): """Return the name of the device.""" - return self.device.name or DEVICE_DEFAULT_NAME + return self.device.title or DEVICE_DEFAULT_NAME @property def session(self): """Return the session, if any.""" - if self.device.machineIdentifier not in self.plex_sessions: - return None - - return self.plex_sessions[self.device.machineIdentifier] + return self.plex_sessions.get(self.device.machineIdentifier, None) @property def state(self): """Return the state of the device.""" - if self.session: + if self.session and self.session.player: state = self.session.player.state if state == 'playing': return STATE_PLAYING @@ -243,11 +245,30 @@ class PlexClient(MediaPlayerDevice): self.update_devices(no_throttle=True) self.update_sessions(no_throttle=True) + # pylint: disable=no-self-use, singleton-comparison + def _convert_na_to_none(self, value): + """Convert PlexAPI _NA() instances to None.""" + # PlexAPI will return a "__NA__" object which can be compared to + # None, but isn't actually None - this converts it to a real None + # type so that lower layers don't think it's a URL and choke on it + if value is self.na_type: + return None + else: + return value + + @property + def _active_media_plexapi_type(self): + """Get the active media type required by PlexAPI commands.""" + if self.media_content_type is MEDIA_TYPE_MUSIC: + return 'music' + else: + return 'video' + @property def media_content_id(self): """Content ID of current playing media.""" if self.session is not None: - return self.session.ratingKey + return self._convert_na_to_none(self.session.ratingKey) @property def media_content_type(self): @@ -259,65 +280,82 @@ class PlexClient(MediaPlayerDevice): return MEDIA_TYPE_TVSHOW elif media_type == 'movie': return MEDIA_TYPE_VIDEO + elif media_type == 'track': + return MEDIA_TYPE_MUSIC return None @property def media_duration(self): """Duration of current playing media in seconds.""" if self.session is not None: - return self.session.duration + return self._convert_na_to_none(self.session.duration) @property def media_image_url(self): """Image url of current playing media.""" if self.session is not None: - return self.session.thumbUrl + thumb_url = self._convert_na_to_none(self.session.thumbUrl) + if str(self.na_type) in thumb_url: + # Audio tracks build their thumb urls internally before passing + # back a URL with the PlexAPI _NA type already converted to a + # string and embedded into a malformed URL + thumb_url = None + return thumb_url @property def media_title(self): """Title of current playing media.""" # find a string we can use as a title if self.session is not None: - return self.session.title + return self._convert_na_to_none(self.session.title) @property def media_season(self): """Season of curent playing media (TV Show only).""" from plexapi.video import Show if isinstance(self.session, Show): - return self.session.seasons()[0].index + return self._convert_na_to_none(self.session.seasons()[0].index) @property def media_series_title(self): """The title of the series of current playing media (TV Show only).""" from plexapi.video import Show if isinstance(self.session, Show): - return self.session.grandparentTitle + return self._convert_na_to_none(self.session.grandparentTitle) @property def media_episode(self): """Episode of current playing media (TV Show only).""" from plexapi.video import Show if isinstance(self.session, Show): - return self.session.index + return self._convert_na_to_none(self.session.index) @property def supported_media_commands(self): """Flag of media commands that are supported.""" return SUPPORT_PLEX + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self.device.setVolume(int(volume * 100), + self._active_media_plexapi_type) + def media_play(self): """Send play command.""" - self.device.play() + self.device.play(self._active_media_plexapi_type) def media_pause(self): """Send pause command.""" - self.device.pause() + self.device.pause(self._active_media_plexapi_type) + + def media_stop(self): + """Send stop command.""" + self.device.stop(self._active_media_plexapi_type) def media_next_track(self): """Send next track command.""" - self.device.skipNext() + self.device.skipNext(self._active_media_plexapi_type) def media_previous_track(self): """Send previous track command.""" - self.device.skipPrevious() + self.device.skipPrevious(self._active_media_plexapi_type) diff --git a/homeassistant/components/media_player/roku.py b/homeassistant/components/media_player/roku.py index 8644a2c9fda..7951530e3e8 100644 --- a/homeassistant/components/media_player/roku.py +++ b/homeassistant/components/media_player/roku.py @@ -88,7 +88,8 @@ class RokuDevice(MediaPlayerDevice): self.current_app = None except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout): - _LOGGER.error("Unable to connect to roku at %s", self.ip_address) + + pass def get_source_list(self): """Get the list of applications to be used as sources.""" diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index 767c2b966a6..b950ec39dd8 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -10,7 +10,7 @@ import socket from homeassistant.const import (ATTR_BATTERY_LEVEL, CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - STATE_OFF, STATE_ON, TEMP_CELSIUS) + STATE_OFF, STATE_ON) from homeassistant.helpers import validate_config, discovery CONF_GATEWAYS = 'gateways' @@ -53,7 +53,7 @@ def setup(hass, config): # pylint: disable=too-many-locals import mysensors.mysensors as mysensors version = str(config[DOMAIN].get(CONF_VERSION, DEFAULT_VERSION)) - is_metric = (hass.config.temperature_unit == TEMP_CELSIUS) + is_metric = hass.config.units.is_metric persistence = config[DOMAIN].get(CONF_PERSISTENCE, True) def setup_gateway(device, persistence_file, baud_rate, tcp_port): diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 5b1c103a1bf..4b73c46b198 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -44,16 +44,19 @@ NOTIFY_SERVICE_SCHEMA = vol.Schema({ _LOGGER = logging.getLogger(__name__) -def send_message(hass, message, title=None): +def send_message(hass, message, title=None, data=None): """Send a notification message.""" - data = { + info = { ATTR_MESSAGE: message } if title is not None: - data[ATTR_TITLE] = title + info[ATTR_TITLE] = title - hass.services.call(DOMAIN, SERVICE_NOTIFY, data) + if data is not None: + info[ATTR_DATA] = data + + hass.services.call(DOMAIN, SERVICE_NOTIFY, info) def setup(hass, config): diff --git a/homeassistant/components/notify/demo.py b/homeassistant/components/notify/demo.py index c051bce020d..4685b90e880 100644 --- a/homeassistant/components/notify/demo.py +++ b/homeassistant/components/notify/demo.py @@ -4,7 +4,7 @@ Demo notification service. For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ -from homeassistant.components.notify import ATTR_TITLE, BaseNotificationService +from homeassistant.components.notify import BaseNotificationService EVENT_NOTIFY = "notify" @@ -24,5 +24,5 @@ class DemoNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to a user.""" - title = kwargs.get(ATTR_TITLE) - self.hass.bus.fire(EVENT_NOTIFY, {"title": title, "message": message}) + kwargs['message'] = message + self.hass.bus.fire(EVENT_NOTIFY, kwargs) diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index 6811fdbd55b..463f5fe0b42 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -10,7 +10,7 @@ from homeassistant.components.notify import ( ATTR_TITLE, DOMAIN, BaseNotificationService) from homeassistant.helpers import validate_config -REQUIREMENTS = ['sendgrid==3.0.7'] +REQUIREMENTS = ['sendgrid==3.1.10'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/smtp.py b/homeassistant/components/notify/smtp.py index 7664753f2ee..27c74571d40 100644 --- a/homeassistant/components/notify/smtp.py +++ b/homeassistant/components/notify/smtp.py @@ -6,14 +6,18 @@ https://home-assistant.io/components/notify.smtp/ """ import logging import smtplib +from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from email.mime.image import MIMEImage from homeassistant.components.notify import ( - ATTR_TITLE, DOMAIN, BaseNotificationService) + ATTR_TITLE, ATTR_DATA, DOMAIN, BaseNotificationService) from homeassistant.helpers import validate_config _LOGGER = logging.getLogger(__name__) +ATTR_IMAGES = 'images' # optional embedded image file attachments + def get_service(hass, config): """Get the mail notification service.""" @@ -22,52 +26,21 @@ def get_service(hass, config): _LOGGER): return None - smtp_server = config.get('server', 'localhost') - port = int(config.get('port', '25')) - username = config.get('username', None) - password = config.get('password', None) - starttls = int(config.get('starttls', 0)) - debug = config.get('debug', 0) - - server = None - try: - server = smtplib.SMTP(smtp_server, port, timeout=5) - server.set_debuglevel(debug) - server.ehlo() - if starttls == 1: - server.starttls() - server.ehlo() - if username and password: - try: - server.login(username, password) - - except (smtplib.SMTPException, smtplib.SMTPSenderRefused): - _LOGGER.exception("Please check your settings.") - return None - - except smtplib.socket.gaierror: - _LOGGER.exception( - "SMTP server not found (%s:%s). " - "Please check the IP address or hostname of your SMTP server.", - smtp_server, port) + mail_service = MailNotificationService( + config.get('server', 'localhost'), + int(config.get('port', '25')), + config.get('sender', None), + int(config.get('starttls', 0)), + config.get('username', None), + config.get('password', None), + config.get('recipient', None), + config.get('debug', 0)) + if mail_service.connection_is_valid(): + return mail_service + else: return None - except smtplib.SMTPAuthenticationError: - _LOGGER.exception( - "Login not possible. " - "Please check your setting and/or your credentials.") - - return None - - finally: - if server: - server.quit() - - return MailNotificationService( - smtp_server, port, config['sender'], starttls, username, password, - config['recipient'], debug) - # pylint: disable=too-few-public-methods, too-many-instance-attributes class MailNotificationService(BaseNotificationService): @@ -99,17 +72,57 @@ class MailNotificationService(BaseNotificationService): mail.login(self.username, self.password) return mail - def send_message(self, message="", **kwargs): - """Send a message to a user.""" - mail = self.connect() - subject = kwargs.get(ATTR_TITLE) + def connection_is_valid(self): + """Check for valid config, verify connectivity.""" + server = None + try: + server = self.connect() + except smtplib.socket.gaierror: + _LOGGER.exception( + "SMTP server not found (%s:%s). " + "Please check the IP address or hostname of your SMTP server.", + self._server, self._port) + + return False + + except (smtplib.SMTPAuthenticationError, ConnectionRefusedError): + _LOGGER.exception( + "Login not possible. " + "Please check your setting and/or your credentials.") + + return False + + finally: + if server: + server.quit() + + return True + + def send_message(self, message="", **kwargs): + """ + Build and send a message to a user. + + Will send plain text normally, or will build a multipart HTML message + with inline image attachments if images config is defined. + """ + subject = kwargs.get(ATTR_TITLE) + data = kwargs.get(ATTR_DATA) + + if data: + msg = _build_multipart_msg(message, images=data.get(ATTR_IMAGES)) + else: + msg = _build_text_msg(message) - msg = MIMEText(message) msg['Subject'] = subject msg['To'] = self.recipient msg['From'] = self._sender msg['X-Mailer'] = 'HomeAssistant' + return self._send_email(msg) + + def _send_email(self, msg): + """Send the message.""" + mail = self.connect() for _ in range(self.tries): try: mail.sendmail(self._sender, self.recipient, @@ -122,3 +135,36 @@ class MailNotificationService(BaseNotificationService): mail = self.connect() mail.quit() + + +def _build_text_msg(message): + """Build plaintext email.""" + _LOGGER.debug('Building plain text email.') + return MIMEText(message) + + +def _build_multipart_msg(message, images): + """Build Multipart message with in-line images.""" + _LOGGER.debug('Building multipart email with embedded attachment(s).') + msg = MIMEMultipart('related') + msg_alt = MIMEMultipart('alternative') + msg.attach(msg_alt) + body_txt = MIMEText(message) + msg_alt.attach(body_txt) + body_text = ['

{}


'.format(message)] + + for atch_num, atch_name in enumerate(images): + cid = 'image{}'.format(atch_num) + body_text.append('
'.format(cid)) + try: + with open(atch_name, 'rb') as attachment_file: + attachment = MIMEImage(attachment_file.read()) + msg.attach(attachment) + attachment.add_header('Content-ID', '<{}>'.format(cid)) + except FileNotFoundError: + _LOGGER.warning('Attachment %s not found. Skipping.', + atch_name) + + body_html = MIMEText(''.join(body_text), 'html') + msg_alt.attach(body_html) + return msg diff --git a/homeassistant/components/panel_custom.py b/homeassistant/components/panel_custom.py new file mode 100644 index 00000000000..7806cc4cac8 --- /dev/null +++ b/homeassistant/components/panel_custom.py @@ -0,0 +1,69 @@ +""" +Register a custom front end panel. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/panel_custom/ +""" +import logging +import os + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.frontend import register_panel + +DOMAIN = 'panel_custom' +DEPENDENCIES = ['frontend'] + +CONF_COMPONENT_NAME = 'name' +CONF_SIDEBAR_TITLE = 'sidebar_title' +CONF_SIDEBAR_ICON = 'sidebar_icon' +CONF_URL_PATH = 'url_path' +CONF_CONFIG = 'config' +CONF_WEBCOMPONENT_PATH = 'webcomponent_path' + +DEFAULT_ICON = 'mdi:bookmark' + +PANEL_DIR = 'panels' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [{ + vol.Required(CONF_COMPONENT_NAME): cv.slug, + vol.Optional(CONF_SIDEBAR_TITLE): cv.string, + vol.Optional(CONF_SIDEBAR_ICON, default=DEFAULT_ICON): cv.icon, + vol.Optional(CONF_URL_PATH): cv.string, + vol.Optional(CONF_CONFIG): cv.match_all, + vol.Optional(CONF_WEBCOMPONENT_PATH): cv.isfile, + }]) +}, extra=vol.ALLOW_EXTRA) + +_LOGGER = logging.getLogger(__name__) + + +def setup(hass, config): + """Initialize custom panel.""" + success = False + + for panel in config.get(DOMAIN): + name = panel.get(CONF_COMPONENT_NAME) + panel_path = panel.get(CONF_WEBCOMPONENT_PATH) + + if panel_path is None: + panel_path = hass.config.path(PANEL_DIR, '{}.html'.format(name)) + + if not os.path.isfile(panel_path): + _LOGGER.error('Unable to find webcomponent for %s: %s', + name, panel_path) + continue + + register_panel( + hass, name, panel_path, + sidebar_title=panel.get(CONF_SIDEBAR_TITLE), + sidebar_icon=panel.get(CONF_SIDEBAR_ICON), + url_path=panel.get(CONF_URL_PATH), + config=panel.get(CONF_CONFIG), + ) + + success = True + + return success diff --git a/homeassistant/components/panel_iframe.py b/homeassistant/components/panel_iframe.py index 30773296aeb..d0f9d20f838 100644 --- a/homeassistant/components/panel_iframe.py +++ b/homeassistant/components/panel_iframe.py @@ -23,9 +23,9 @@ CONFIG_SCHEMA = vol.Schema({ def setup(hass, config): """Setup iframe frontend panels.""" - for url_name, info in config[DOMAIN].items(): + for url_path, info in config[DOMAIN].items(): register_built_in_panel( hass, 'iframe', info.get(CONF_TITLE), info.get(CONF_ICON), - url_name, {'url': info[CONF_URL]}) + url_path, {'url': info[CONF_URL]}) return True diff --git a/homeassistant/components/pilight.py b/homeassistant/components/pilight.py new file mode 100644 index 00000000000..07771acee00 --- /dev/null +++ b/homeassistant/components/pilight.py @@ -0,0 +1,109 @@ +""" +Component to create an interface to a Pilight daemon (https://pilight.org/). + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/pilight/ +""" +# pylint: disable=import-error +import logging +import socket + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import ensure_list +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_HOST, CONF_PORT + +REQUIREMENTS = ['pilight==0.0.2'] + +DOMAIN = "pilight" +EVENT = 'pilight_received' +SERVICE_NAME = 'send' + +CONF_WHITELIST = 'whitelist' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST, default='127.0.0.1'): cv.string, + vol.Required(CONF_PORT, default=5000): vol.Coerce(int), + vol.Optional(CONF_WHITELIST): {cv.string: [cv.string]} + }), +}, extra=vol.ALLOW_EXTRA) + +# The pilight code schema depends on the protocol +# Thus only require to have the protocol information +ATTR_PROTOCOL = 'protocol' +RF_CODE_SCHEMA = vol.Schema({vol.Required(ATTR_PROTOCOL): cv.string}, + extra=vol.ALLOW_EXTRA) + +_LOGGER = logging.getLogger(__name__) + + +def setup(hass, config): + """Setup the pilight component.""" + from pilight import pilight + + try: + pilight_client = pilight.Client(host=config[DOMAIN][CONF_HOST], + port=config[DOMAIN][CONF_PORT]) + except (socket.error, socket.timeout) as err: + _LOGGER.error( + "Unable to connect to %s on port %s: %s", + config[CONF_HOST], config[CONF_PORT], err) + return False + + # Start / stop pilight-daemon connection with HA start/stop + def start_pilight_client(_): + """Called once when home assistant starts.""" + pilight_client.start() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_pilight_client) + + def stop_pilight_client(_): + """Called once when home assistant stops.""" + pilight_client.stop() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_pilight_client) + + def send_code(call): + """Send RF code to the pilight-daemon.""" + message_data = call.data + + # Patch data because of bug: + # https://github.com/pilight/pilight/issues/296 + # Protocol has to be in a list otherwise segfault in pilight-daemon + message_data["protocol"] = ensure_list(message_data["protocol"]) + + try: + pilight_client.send_code(message_data) + except IOError: + _LOGGER.error('Pilight send failed for %s', str(message_data)) + + hass.services.register(DOMAIN, SERVICE_NAME, + send_code, schema=RF_CODE_SCHEMA) + + # Publish received codes on the HA event bus + # A whitelist of codes to be published in the event bus + whitelist = config[DOMAIN].get('whitelist', False) + + def handle_received_code(data): + """Called when RF codes are received.""" + # Unravel dict of dicts to make event_data cut in automation rule + # possible + data = dict( + {'protocol': data['protocol'], + 'uuid': data['uuid']}, + **data['message']) + + # No whitelist defined, put data on event bus + if not whitelist: + hass.bus.fire(EVENT, data) + # Check if data matches the defined whitelist + elif all(data[key] in whitelist[key] for key in whitelist): + hass.bus.fire(EVENT, data) + + pilight_client.set_callback(handle_received_code) + + return True diff --git a/homeassistant/components/proximity.py b/homeassistant/components/proximity.py index 5880df639bd..ba0a192398f 100644 --- a/homeassistant/components/proximity.py +++ b/homeassistant/components/proximity.py @@ -12,17 +12,30 @@ import logging from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_state_change from homeassistant.util.location import distance +from homeassistant.util.distance import convert +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT DEPENDENCIES = ['zone', 'device_tracker'] DOMAIN = 'proximity' +NOT_SET = 'not set' + # Default tolerance DEFAULT_TOLERANCE = 1 # Default zone DEFAULT_PROXIMITY_ZONE = 'home' +# Default distance to zone +DEFAULT_DIST_TO_ZONE = NOT_SET + +# Default direction of travel +DEFAULT_DIR_OF_TRAVEL = NOT_SET + +# Default nearest device +DEFAULT_NEAREST = NOT_SET + # Entity attributes ATTR_DIST_FROM = 'dist_to_zone' ATTR_DIR_OF_TRAVEL = 'dir_of_travel' @@ -31,43 +44,41 @@ ATTR_NEAREST = 'nearest' _LOGGER = logging.getLogger(__name__) -def setup(hass, config): # pylint: disable=too-many-locals,too-many-statements - """Get the zones and offsets from configuration.yaml.""" - ignored_zones = [] - if 'ignored_zones' in config[DOMAIN]: - for variable in config[DOMAIN]['ignored_zones']: - ignored_zones.append(variable) - +def setup_proximity_component(hass, config): + """Set up individual proximity component.""" # Get the devices from configuration.yaml. - if 'devices' not in config[DOMAIN]: + if 'devices' not in config: _LOGGER.error('devices not found in config') return False + ignored_zones = [] + if 'ignored_zones' in config: + for variable in config['ignored_zones']: + ignored_zones.append(variable) + proximity_devices = [] - for variable in config[DOMAIN]['devices']: + for variable in config['devices']: proximity_devices.append(variable) # Get the direction of travel tolerance from configuration.yaml. - tolerance = config[DOMAIN].get('tolerance', DEFAULT_TOLERANCE) + tolerance = config.get('tolerance', DEFAULT_TOLERANCE) # Get the zone to monitor proximity to from configuration.yaml. - proximity_zone = config[DOMAIN].get('zone', DEFAULT_PROXIMITY_ZONE) + proximity_zone = config.get('zone', DEFAULT_PROXIMITY_ZONE) - entity_id = DOMAIN + '.' + proximity_zone - proximity_zone = 'zone.' + proximity_zone + # Get the unit of measurement from configuration.yaml. + unit_of_measure = config.get(ATTR_UNIT_OF_MEASUREMENT, + hass.config.units.length_unit) - state = hass.states.get(proximity_zone) + zone_id = 'zone.{}'.format(proximity_zone) + state = hass.states.get(zone_id) zone_friendly_name = (state.name).lower() - # Set the default values. - dist_to_zone = 'not set' - dir_of_travel = 'not set' - nearest = 'not set' - - proximity = Proximity(hass, zone_friendly_name, dist_to_zone, - dir_of_travel, nearest, ignored_zones, - proximity_devices, tolerance, proximity_zone) - proximity.entity_id = entity_id + proximity = Proximity(hass, zone_friendly_name, DEFAULT_DIST_TO_ZONE, + DEFAULT_DIR_OF_TRAVEL, DEFAULT_NEAREST, + ignored_zones, proximity_devices, tolerance, + zone_id, unit_of_measure) + proximity.entity_id = '{}.{}'.format(DOMAIN, proximity_zone) proximity.update_ha_state() @@ -78,13 +89,26 @@ def setup(hass, config): # pylint: disable=too-many-locals,too-many-statements return True +def setup(hass, config): + """Get the zones and offsets from configuration.yaml.""" + result = True + if isinstance(config[DOMAIN], list): + for proximity_config in config[DOMAIN]: + if not setup_proximity_component(hass, proximity_config): + result = False + elif not setup_proximity_component(hass, config[DOMAIN]): + result = False + + return result + + class Proximity(Entity): # pylint: disable=too-many-instance-attributes """Representation of a Proximity.""" # pylint: disable=too-many-arguments def __init__(self, hass, zone_friendly_name, dist_to, dir_of_travel, nearest, ignored_zones, proximity_devices, tolerance, - proximity_zone): + proximity_zone, unit_of_measure): """Initialize the proximity.""" self.hass = hass self.friendly_name = zone_friendly_name @@ -95,6 +119,7 @@ class Proximity(Entity): # pylint: disable=too-many-instance-attributes self.proximity_devices = proximity_devices self.tolerance = tolerance self.proximity_zone = proximity_zone + self.unit_of_measure = unit_of_measure @property def name(self): @@ -109,7 +134,7 @@ class Proximity(Entity): # pylint: disable=too-many-instance-attributes @property def unit_of_measurement(self): """Return the unit of measurement of this entity.""" - return "km" + return self.unit_of_measure @property def state_attributes(self): @@ -183,15 +208,16 @@ class Proximity(Entity): # pylint: disable=too-many-instance-attributes device_state.attributes['longitude']) # Add the device and distance to a dictionary. - distances_to_zone[device] = round(dist_to_zone / 1000, 1) + distances_to_zone[device] = round( + convert(dist_to_zone, 'm', self.unit_of_measure), 1) # Loop through each of the distances collected and work out the # closest. - closest_device = '' - dist_to_zone = 1000000 + closest_device = None # type: str + dist_to_zone = None # type: float for device in distances_to_zone: - if distances_to_zone[device] < dist_to_zone: + if not dist_to_zone or distances_to_zone[device] < dist_to_zone: closest_device = device dist_to_zone = distances_to_zone[device] diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index f5ce7118d01..5e4415d81a0 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -11,15 +11,18 @@ import logging import queue import threading import time -from datetime import timedelta +from datetime import timedelta, datetime +from typing import Any, Union, Optional, List import voluptuous as vol -import homeassistant.util.dt as dt_util +from homeassistant.core import HomeAssistant from homeassistant.const import (EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL) from homeassistant.helpers.event import track_point_in_utc_time +from homeassistant.helpers.typing import ConfigType, QueryType +import homeassistant.util.dt as dt_util DOMAIN = "recorder" @@ -44,15 +47,16 @@ CONFIG_SCHEMA = vol.Schema({ }) }, extra=vol.ALLOW_EXTRA) -_INSTANCE = None +_INSTANCE = None # type: Any _LOGGER = logging.getLogger(__name__) # These classes will be populated during setup() -# pylint: disable=invalid-name -Session = None +# pylint: disable=invalid-name,no-member +Session = None # pylint: disable=no-member -def execute(q): +def execute(q: QueryType) \ + -> List[Any]: # pylint: disable=invalid-sequence-index """Query the database and convert the objects to HA native form. This method also retries a few times in the case of stale connections. @@ -68,11 +72,11 @@ def execute(q): except sqlalchemy.exc.SQLAlchemyError as e: log_error(e, retry_wait=QUERY_RETRY_WAIT, rollback=True) finally: - Session().close() + Session.close() return [] -def run_information(point_in_time=None): +def run_information(point_in_time: Optional[datetime]=None): """Return information about current run. There is also the run that covers point_in_time. @@ -91,7 +95,7 @@ def run_information(point_in_time=None): (recorder_runs.end > point_in_time)).first() -def setup(hass, config): +def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Setup the recorder.""" # pylint: disable=global-statement global _INSTANCE @@ -112,30 +116,36 @@ def setup(hass, config): return True -def query(model_name, *args): +def query(model_name: Union[str, Any], *args) -> QueryType: """Helper to return a query handle.""" + _verify_instance() + if isinstance(model_name, str): - return Session().query(get_model(model_name), *args) - return Session().query(model_name, *args) + return Session.query(get_model(model_name), *args) + return Session.query(model_name, *args) -def get_model(model_name): +def get_model(model_name: str) -> Any: """Get a model class.""" from homeassistant.components.recorder import models - - return getattr(models, model_name) + try: + return getattr(models, model_name) + except AttributeError: + _LOGGER.error("Invalid model name %s", model_name) + return None -def log_error(e, retry_wait=0, rollback=True, - message="Error during query: %s"): +def log_error(e: Exception, retry_wait: Optional[float]=0, + rollback: Optional[bool]=True, + message: Optional[str]="Error during query: %s") -> None: """Log about SQLAlchemy errors in a sane manner.""" import sqlalchemy.exc if not isinstance(e, sqlalchemy.exc.OperationalError): - _LOGGER.exception(e) + _LOGGER.exception(str(e)) else: _LOGGER.error(message, str(e)) if rollback: - Session().rollback() + Session.rollback() if retry_wait: _LOGGER.info("Retrying in %s seconds", retry_wait) time.sleep(retry_wait) @@ -145,19 +155,20 @@ class Recorder(threading.Thread): """A threaded recorder class.""" # pylint: disable=too-many-instance-attributes - def __init__(self, hass, purge_days, uri): + def __init__(self, hass: HomeAssistant, purge_days: int, uri: str) \ + -> None: """Initialize the recorder.""" threading.Thread.__init__(self) self.hass = hass self.purge_days = purge_days - self.queue = queue.Queue() + self.queue = queue.Queue() # type: Any self.quit_object = object() self.recording_start = dt_util.utcnow() self.db_url = uri self.db_ready = threading.Event() - self.engine = None - self._run = None + self.engine = None # type: Any + self._run = None # type: Any def start_recording(event): """Start recording.""" @@ -276,7 +287,7 @@ class Recorder(threading.Thread): run.end = self.recording_start _LOGGER.warning("Ended unfinished session (id=%s from %s)", run.run_id, run.start) - Session().add(run) + Session.add(run) _LOGGER.warning("Found unfinished sessions") @@ -321,7 +332,7 @@ class Recorder(threading.Thread): if self._commit(_purge_events): _LOGGER.info("Purged events created before %s", purge_before) - Session().expire_all() + Session.expire_all() # Execute sqlite vacuum command to free up space on disk if self.engine.driver == 'sqlite': @@ -346,7 +357,8 @@ class Recorder(threading.Thread): return False -def _verify_instance(): +def _verify_instance() -> None: """Throw error if recorder not initialized.""" if _INSTANCE is None: raise RuntimeError("Recorder not initialized.") + _INSTANCE.block_till_db_ready() diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index fdb5642562f..6e3e2db064d 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -9,9 +9,8 @@ from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Index, Integer, from sqlalchemy.ext.declarative import declarative_base import homeassistant.util.dt as dt_util -from homeassistant.core import Event, EventOrigin, State +from homeassistant.core import Event, EventOrigin, State, split_entity_id from homeassistant.remote import JSONEncoder -from homeassistant.helpers.entity import split_entity_id # SQLAlchemy Schema # pylint: disable=invalid-name @@ -62,7 +61,7 @@ class States(Base): # type: ignore __tablename__ = 'states' state_id = Column(Integer, primary_key=True) domain = Column(String(64)) - entity_id = Column(String(64)) + entity_id = Column(String(255)) state = Column(String(255)) attributes = Column(Text) event_id = Column(Integer, ForeignKey('events.event_id')) diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index d684d319117..f15bd703ca1 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -14,7 +14,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.entity import Entity from homeassistant.const import (ATTR_ENTITY_ID, TEMP_CELSIUS) -REQUIREMENTS = ['pyRFXtrx==0.9.0'] +REQUIREMENTS = ['pyRFXtrx==0.10.1'] DOMAIN = "rfxtrx" @@ -66,6 +66,7 @@ def _valid_device(value, device_type): key = device.get('packetid') device.pop('packetid') + key = str(key) if not len(key) % 2 == 0: key = '0' + key @@ -130,7 +131,11 @@ def setup(hass, config): # Log RFXCOM event if not event.device.id_string: return - _LOGGER.info("Receive RFXCOM event from %s", event.device) + _LOGGER.info("Receive RFXCOM event from " + "(Device_id: %s Class: %s Sub: %s)", + slugify(event.device.id_string.lower()), + event.device.__class__.__name__, + event.device.subtype) # Callback to HA registered components. for subscriber in RECEIVED_EVT_SUBSCRIBERS: @@ -213,13 +218,14 @@ def get_new_device(event, config, device): if not config[ATTR_AUTOMATIC_ADD]: return + pkt_id = "".join("{0:02x}".format(x) for x in event.data) _LOGGER.info( - "Automatic add %s rfxtrx device (Class: %s Sub: %s)", + "Automatic add %s rfxtrx device (Class: %s Sub: %s Packet_id: %s)", device_id, event.device.__class__.__name__, - event.device.subtype + event.device.subtype, + pkt_id ) - pkt_id = "".join("{0:02x}".format(x) for x in event.data) datas = {ATTR_STATE: False, ATTR_FIREEVENT: False} signal_repetitions = config[CONF_SIGNAL_REPETITIONS] new_device = device(pkt_id, event, datas, @@ -236,7 +242,7 @@ def apply_received_command(event): return _LOGGER.debug( - "EntityID: %s device_update. Command: %s", + "Device_id: %s device_update. Command: %s", device_id, event.values['Command'] ) diff --git a/homeassistant/components/rollershutter/services.yaml b/homeassistant/components/rollershutter/services.yaml index e69de29bb2d..b7ef0a17643 100644 --- a/homeassistant/components/rollershutter/services.yaml +++ b/homeassistant/components/rollershutter/services.yaml @@ -0,0 +1,23 @@ +move_up: + description: Move up all or specified roller shutter + + fields: + entity_id: + description: Name(s) of roller shutter(s) to move up + example: 'rollershutter.living_room' + +move_down: + description: Move down all or specified roller shutter + + fields: + entity_id: + description: Name(s) of roller shutter(s) to move down + example: 'rollershutter.living_room' + +stop: + description: Stop all or specified roller shutter + + fields: + entity_id: + description: Name(s) of roller shutter(s) to stop + example: 'rollershutter.living_room' diff --git a/homeassistant/components/rollershutter/zwave.py b/homeassistant/components/rollershutter/zwave.py index 18a24e41232..01d6980a795 100644 --- a/homeassistant/components/rollershutter/zwave.py +++ b/homeassistant/components/rollershutter/zwave.py @@ -40,10 +40,14 @@ class ZwaveRollershutter(zwave.ZWaveDeviceEntity, RollershutterDevice): def __init__(self, value): """Initialize the zwave rollershutter.""" + import libopenzwave from openzwave.network import ZWaveNetwork from pydispatch import dispatcher ZWaveDeviceEntity.__init__(self, value, DOMAIN) + self._lozwmgr = libopenzwave.PyManager() + self._lozwmgr.create() self._node = value.node + self._current_position = None dispatcher.connect( self.value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED) @@ -51,32 +55,53 @@ class ZwaveRollershutter(zwave.ZWaveDeviceEntity, RollershutterDevice): """Called when a value has changed on the network.""" if self._value.value_id == value.value_id or \ self._value.node == value.node: + self.update_properties() self.update_ha_state() _LOGGER.debug("Value changed on network %s", value) + def update_properties(self): + """Callback on data change for the registered node/value pair.""" + # Position value + for value in self._node.get_values( + class_id=COMMAND_CLASS_SWITCH_MULTILEVEL).values(): + if value.command_class == zwave.COMMAND_CLASS_SWITCH_MULTILEVEL \ + and value.label == 'Level': + self._current_position = value.data + @property def current_position(self): """Return the current position of Zwave roller shutter.""" - if self._value.data <= 5: - return 100 - elif self._value.data >= 95: - return 0 - else: - return 100 - self._value.data + if self._current_position is not None: + if self._current_position <= 5: + return 100 + elif self._current_position >= 95: + return 0 + else: + return 100 - self._current_position def move_up(self, **kwargs): """Move the roller shutter up.""" - self._node.set_dimmer(self._value.value_id, 100) + for value in self._node.get_values( + class_id=COMMAND_CLASS_SWITCH_MULTILEVEL).values(): + if value.command_class == zwave.COMMAND_CLASS_SWITCH_MULTILEVEL \ + and value.label == 'Open': + self._lozwmgr.pressButton(value.value_id) + break def move_down(self, **kwargs): """Move the roller shutter down.""" - self._node.set_dimmer(self._value.value_id, 0) + for value in self._node.get_values( + class_id=COMMAND_CLASS_SWITCH_MULTILEVEL).values(): + if value.command_class == zwave.COMMAND_CLASS_SWITCH_MULTILEVEL \ + and value.label == 'Close': + self._lozwmgr.pressButton(value.value_id) + break def stop(self, **kwargs): """Stop the roller shutter.""" for value in self._node.get_values( - class_id=COMMAND_CLASS_SWITCH_BINARY).values(): - # Rollershutter will toggle between UP (True), DOWN (False). - # It also stops the shutter if the same value is sent while moving. - value.data = value.data - break + class_id=COMMAND_CLASS_SWITCH_MULTILEVEL).values(): + if value.command_class == zwave.COMMAND_CLASS_SWITCH_MULTILEVEL \ + and value.label == 'Open': + self._lozwmgr.releaseButton(value.value_id) + break diff --git a/homeassistant/components/script.py b/homeassistant/components/script.py index 5f1e63f5d00..b235c4d4eb7 100644 --- a/homeassistant/components/script.py +++ b/homeassistant/components/script.py @@ -14,7 +14,8 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_TOGGLE, STATE_ON, CONF_ALIAS) -from homeassistant.helpers.entity import ToggleEntity, split_entity_id +from homeassistant.core import split_entity_id +from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/sensor/dht.py b/homeassistant/components/sensor/dht.py index a17f952aaf7..ec33b1e4042 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -10,6 +10,7 @@ from datetime import timedelta from homeassistant.const import TEMP_FAHRENHEIT from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle +from homeassistant.util.temperature import celsius_to_fahrenheit # Update this requirement to upstream as soon as it supports Python 3. REQUIREMENTS = ['http://github.com/mala-zaba/Adafruit_Python_DHT/archive/' @@ -32,8 +33,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): # pylint: disable=import-error import Adafruit_DHT - SENSOR_TYPES['temperature'][1] = hass.config.temperature_unit - unit = hass.config.temperature_unit + SENSOR_TYPES['temperature'][1] = hass.config.units.temperature_unit available_sensors = { "DHT11": Adafruit_DHT.DHT11, "DHT22": Adafruit_DHT.DHT22, @@ -58,7 +58,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if variable not in SENSOR_TYPES: _LOGGER.error('Sensor type: "%s" does not exist', variable) else: - dev.append(DHTSensor(data, variable, unit, name)) + dev.append( + DHTSensor(data, variable, SENSOR_TYPES[variable][1], name)) except KeyError: pass @@ -103,7 +104,8 @@ class DHTSensor(Entity): if self.type == 'temperature': self._state = round(data['temperature'], 1) if self.temp_unit == TEMP_FAHRENHEIT: - self._state = round(data['temperature'] * 1.8 + 32, 1) + self._state = round(celsius_to_fahrenheit(data['temperature']), + 1) elif self.type == 'humidity': self._state = round(data['humidity'], 1) diff --git a/homeassistant/components/sensor/fastdotcom.py b/homeassistant/components/sensor/fastdotcom.py new file mode 100644 index 00000000000..fbdf6220c91 --- /dev/null +++ b/homeassistant/components/sensor/fastdotcom.py @@ -0,0 +1,104 @@ +""" +Support for Fast.com internet speed testing sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.fastdotcom/ +""" +import logging + +import homeassistant.util.dt as dt_util +from homeassistant.components import recorder +from homeassistant.components.sensor import DOMAIN +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_time_change + +REQUIREMENTS = ['https://github.com/nkgilley/fast.com/archive/' + 'master.zip#fastdotcom==0.0.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_SECOND = 'second' +CONF_MINUTE = 'minute' +CONF_HOUR = 'hour' +CONF_DAY = 'day' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Fast.com sensor.""" + data = SpeedtestData(hass, config) + sensor = SpeedtestSensor(data) + add_devices([sensor]) + + def update(call=None): + """Update service for manual updates.""" + data.update(dt_util.now()) + sensor.update() + + hass.services.register(DOMAIN, 'update_fastdotcom', update) + + +# pylint: disable=too-few-public-methods +class SpeedtestSensor(Entity): + """Implementation of a FAst.com sensor.""" + + def __init__(self, speedtest_data): + """Initialize the sensor.""" + self._name = 'Fast.com Speedtest' + self.speedtest_client = speedtest_data + self._state = None + self._unit_of_measurement = 'Mbps' + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + def update(self): + """Get the latest data and update the states.""" + data = self.speedtest_client.data + if data is None: + entity_id = 'sensor.fastcom_speedtest' + states = recorder.get_model('States') + try: + last_state = recorder.execute( + recorder.query('States').filter( + (states.entity_id == entity_id) & + (states.last_changed == states.last_updated) & + (states.state != 'unknown') + ).order_by(states.state_id.desc()).limit(1)) + except TypeError: + return + if not last_state: + return + self._state = last_state[0].state + else: + self._state = data['download'] + + +class SpeedtestData(object): + """Get the latest data from fast.com.""" + + def __init__(self, hass, config): + """Initialize the data object.""" + self.data = None + track_time_change(hass, self.update, + second=config.get(CONF_SECOND, 0), + minute=config.get(CONF_MINUTE, 0), + hour=config.get(CONF_HOUR, None), + day=config.get(CONF_DAY, None)) + + def update(self, now): + """Get the latest data from fast.com.""" + from fastdotcom import fast_com + _LOGGER.info('Executing fast.com speedtest') + self.data = {'download': fast_com()} diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index 8bbedf4dd2f..eb87527a546 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -10,7 +10,6 @@ import logging import datetime import time -from homeassistant.const import TEMP_CELSIUS from homeassistant.util import Throttle from homeassistant.helpers.entity import Entity from homeassistant.loader import get_component @@ -233,13 +232,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): authd_client.client.refresh_token() authd_client.system = authd_client.user_profile_get()["user"]["locale"] + if authd_client.system != 'en_GB': + if hass.config.units.is_metric: + authd_client.system = "metric" + else: + authd_client.system = "en_US" dev = [] for resource in config.get("monitored_resources", FITBIT_DEFAULT_RESOURCE_LIST): dev.append(FitbitSensor(authd_client, config_path, resource, - hass.config.temperature_unit == - TEMP_CELSIUS)) + hass.config.units.is_metric)) add_devices(dev) else: diff --git a/homeassistant/components/sensor/forecast.py b/homeassistant/components/sensor/forecast.py index cca1b7d52d7..1a569d3d4c3 100644 --- a/homeassistant/components/sensor/forecast.py +++ b/homeassistant/components/sensor/forecast.py @@ -10,7 +10,7 @@ from requests.exceptions import ConnectionError as ConnectError, \ HTTPError, Timeout from homeassistant.components.sensor import DOMAIN -from homeassistant.const import CONF_API_KEY, TEMP_CELSIUS +from homeassistant.const import CONF_API_KEY from homeassistant.helpers import validate_config from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -62,7 +62,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if 'units' in config: units = config['units'] - elif hass.config.temperature_unit == TEMP_CELSIUS: + elif hass.config.units.is_metric: units = 'si' else: units = 'us' diff --git a/homeassistant/components/sensor/google_travel_time.py b/homeassistant/components/sensor/google_travel_time.py index be42ddf8382..378e9c9c124 100644 --- a/homeassistant/components/sensor/google_travel_time.py +++ b/homeassistant/components/sensor/google_travel_time.py @@ -11,8 +11,7 @@ import voluptuous as vol from homeassistant.helpers.entity import Entity from homeassistant.const import ( - CONF_API_KEY, TEMP_CELSIUS, TEMP_FAHRENHEIT, - EVENT_HOMEASSISTANT_START, ATTR_LATITUDE, ATTR_LONGITUDE) + CONF_API_KEY, EVENT_HOMEASSISTANT_START, ATTR_LATITUDE, ATTR_LONGITUDE) from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -92,10 +91,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): options = config.get(CONF_OPTIONS) if options.get('units') is None: - if hass.config.temperature_unit is TEMP_CELSIUS: - options['units'] = 'metric' - elif hass.config.temperature_unit is TEMP_FAHRENHEIT: - options['units'] = 'imperial' + options['units'] = hass.config.units.name travel_mode = config.get(CONF_TRAVEL_MODE) mode = options.get(CONF_MODE) diff --git a/homeassistant/components/sensor/gpsd.py b/homeassistant/components/sensor/gpsd.py new file mode 100644 index 00000000000..a466ff32f7d --- /dev/null +++ b/homeassistant/components/sensor/gpsd.py @@ -0,0 +1,111 @@ +""" +Support for GPSD. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.gpsd/ +""" +import logging + +import voluptuous as vol + +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (ATTR_LATITUDE, ATTR_LONGITUDE, STATE_UNKNOWN, + CONF_HOST, CONF_PORT, CONF_PLATFORM, + CONF_NAME) + +REQUIREMENTS = ['gps3==0.33.2'] + +DEFAULT_NAME = 'GPS' +DEFAULT_HOST = 'localhost' +DEFAULT_PORT = 2947 + +ATTR_GPS_TIME = 'gps_time' +ATTR_ELEVATION = 'elevation' +ATTR_SPEED = 'speed' +ATTR_CLIMB = 'climb' +ATTR_MODE = 'mode' + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'gpsd', + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_PORT): cv.string, +}) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the GPSD component.""" + name = config.get(CONF_NAME, DEFAULT_NAME) + host = config.get(CONF_HOST, DEFAULT_HOST) + port = config.get(CONF_PORT, DEFAULT_PORT) + + # Will hopefully be possible with the next gps3 update + # https://github.com/wadda/gps3/issues/11 + # from gps3 import gps3 + # try: + # gpsd_socket = gps3.GPSDSocket() + # gpsd_socket.connect(host=host, port=port) + # except GPSError: + # _LOGGER.warning('Not able to connect to GPSD') + # return False + import socket + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.connect((host, port)) + sock.shutdown(2) + _LOGGER.debug('Connection to GPSD possible') + except socket.error: + _LOGGER.error('Not able to connect to GPSD') + return False + + add_devices([GpsdSensor(hass, name, host, port)]) + + +class GpsdSensor(Entity): + """Representation of a GPS receiver available via GPSD.""" + + def __init__(self, hass, name, host, port): + """Initialize the GPSD sensor.""" + from gps3.agps3threaded import AGPS3mechanism + + self.hass = hass + self._name = name + self._host = host + self._port = port + + self.agps_thread = AGPS3mechanism() + self.agps_thread.stream_data(host=self._host, port=self._port) + self.agps_thread.run_thread() + + @property + def name(self): + """Return the name.""" + return self._name + + # pylint: disable=no-member + @property + def state(self): + """Return the state of GPSD.""" + if self.agps_thread.data_stream.mode == 3: + return "3D Fix" + elif self.agps_thread.data_stream.mode == 2: + return "2D Fix" + else: + return STATE_UNKNOWN + + @property + def state_attributes(self): + """Return the state attributes of the GPS.""" + return { + ATTR_LATITUDE: self.agps_thread.data_stream.lat, + ATTR_LONGITUDE: self.agps_thread.data_stream.lon, + ATTR_ELEVATION: self.agps_thread.data_stream.alt, + ATTR_GPS_TIME: self.agps_thread.data_stream.time, + ATTR_SPEED: self.agps_thread.data_stream.speed, + ATTR_CLIMB: self.agps_thread.data_stream.climb, + ATTR_MODE: self.agps_thread.data_stream.mode, + } diff --git a/homeassistant/components/sensor/mold_indicator.py b/homeassistant/components/sensor/mold_indicator.py index 8f45647f5a2..4e59cd2cd62 100644 --- a/homeassistant/components/sensor/mold_indicator.py +++ b/homeassistant/components/sensor/mold_indicator.py @@ -65,7 +65,7 @@ class MoldIndicator(Entity): self._indoor_humidity_sensor = indoor_humidity_sensor self._outdoor_temp_sensor = outdoor_temp_sensor self._calib_factor = calib_factor - self._is_metric = (hass.config.temperature_unit == TEMP_CELSIUS) + self._is_metric = hass.config.units.is_metric self._dewpoint = None self._indoor_temp = None @@ -109,7 +109,7 @@ class MoldIndicator(Entity): # convert to celsius if necessary if unit == TEMP_FAHRENHEIT: - return util.temperature.fahrenheit_to_celcius(temp) + return util.temperature.fahrenheit_to_celsius(temp) elif unit == TEMP_CELSIUS: return temp else: @@ -260,9 +260,9 @@ class MoldIndicator(Entity): else: return { ATTR_DEWPOINT: - util.temperature.celcius_to_fahrenheit( + util.temperature.celsius_to_fahrenheit( self._dewpoint), ATTR_CRITICAL_TEMP: - util.temperature.celcius_to_fahrenheit( + util.temperature.celsius_to_fahrenheit( self._crit_temp), } diff --git a/homeassistant/components/sensor/octoprint.py b/homeassistant/components/sensor/octoprint.py index 4bf543f3831..f7e7fa30817 100644 --- a/homeassistant/components/sensor/octoprint.py +++ b/homeassistant/components/sensor/octoprint.py @@ -95,6 +95,9 @@ class OctoPrintSensor(Entity): """Return the state of the sensor.""" sensor_unit = self.unit_of_measurement if sensor_unit == TEMP_CELSIUS or sensor_unit == "%": + # API sometimes returns null and not 0 + if self._state is None: + self._state = 0 return round(self._state, 2) else: return self._state diff --git a/homeassistant/components/sensor/ohmconnect.py b/homeassistant/components/sensor/ohmconnect.py new file mode 100644 index 00000000000..fcd50d8edc5 --- /dev/null +++ b/homeassistant/components/sensor/ohmconnect.py @@ -0,0 +1,74 @@ +""" +Support for OhmConnect. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/sensor.ohmconnect/ +""" +import logging +from datetime import timedelta +import xml.etree.ElementTree as ET +import requests + +from homeassistant.util import Throttle +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +# Return cached results if last scan was less then this time ago. +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the OhmConnect sensors.""" + ohmid = config.get("id") + if ohmid is None: + _LOGGER.error("You must provide your OhmConnect ID!") + return False + + add_devices([OhmconnectSensor(config.get("name", "OhmConnect Status"), + ohmid)]) + + +class OhmconnectSensor(Entity): + """Representation of a OhmConnect sensor.""" + + def __init__(self, name, ohmid): + """Initialize the sensor.""" + self._name = name + self._ohmid = ohmid + self._data = {} + self.update() + + @property + def name(self): + """The name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + if self._data.get("active") == "True": + return "Active" + else: + return "Inactive" + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {"Address": self._data.get("address"), "ID": self._ohmid} + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from OhmConnect.""" + try: + url = ("https://login.ohmconnect.com" + "/verify-ohm-hour/{}").format(self._ohmid) + response = requests.get(url, timeout=10) + root = ET.fromstring(response.text) + + for child in root: + self._data[child.tag] = child.text + except requests.exceptions.ConnectionError: + _LOGGER.error("No route to host/endpoint: %s", url) + self.data = {} diff --git a/homeassistant/components/sensor/openweathermap.py b/homeassistant/components/sensor/openweathermap.py index 060f3bd57e8..efaa8d450b4 100644 --- a/homeassistant/components/sensor/openweathermap.py +++ b/homeassistant/components/sensor/openweathermap.py @@ -48,8 +48,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): from pyowm import OWM - SENSOR_TYPES['temperature'][1] = hass.config.temperature_unit - unit = hass.config.temperature_unit + SENSOR_TYPES['temperature'][1] = hass.config.units.temperature_unit forecast = config.get('forecast') owm = OWM(config.get(CONF_API_KEY, None)) @@ -67,13 +66,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if variable not in SENSOR_TYPES: _LOGGER.error('Sensor type: "%s" does not exist', variable) else: - dev.append(OpenWeatherMapSensor(data, variable, unit)) + dev.append(OpenWeatherMapSensor(data, variable, + SENSOR_TYPES[variable][1])) except KeyError: pass if forecast: SENSOR_TYPES['forecast'] = ['Forecast', None] - dev.append(OpenWeatherMapSensor(data, 'forecast', unit)) + dev.append(OpenWeatherMapSensor(data, 'forecast', + SENSOR_TYPES['temperature'][1])) add_devices(dev) diff --git a/homeassistant/components/sensor/plex.py b/homeassistant/components/sensor/plex.py index e35cb54cab8..89ffd020bdd 100644 --- a/homeassistant/components/sensor/plex.py +++ b/homeassistant/components/sensor/plex.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['plexapi==1.1.0'] +REQUIREMENTS = ['plexapi==2.0.2'] CONF_SERVER = 'server' MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) @@ -54,15 +54,18 @@ class PlexSensor(Entity): # pylint: disable=too-many-arguments def __init__(self, name, plex_url, plex_user, plex_password, plex_server): """Initialize the sensor.""" + from plexapi.utils import NA + + self._na_type = NA self._name = name self._state = 0 self._now_playing = [] if plex_user and plex_password: - from plexapi.myplex import MyPlexUser - user = MyPlexUser.signin(plex_user, plex_password) + from plexapi.myplex import MyPlexAccount + user = MyPlexAccount.signin(plex_user, plex_password) server = plex_server if plex_server else user.resources()[0].name - self._server = user.getResource(server).connect() + self._server = user.resource(server).connect() else: from plexapi.server import PlexServer self._server = PlexServer(plex_url) @@ -93,7 +96,11 @@ class PlexSensor(Entity): def update(self): """Update method for plex sensor.""" sessions = self._server.sessions() - now_playing = [(s.user.title, "{0} ({1})".format(s.title, s.year)) - for s in sessions] + now_playing = [] + for sess in sessions: + user = sess.user.title if sess.user is not self._na_type else "" + title = sess.title if sess.title is not self._na_type else "" + year = sess.year if sess.year is not self._na_type else "" + now_playing.append((user, "{0} ({1})".format(title, year))) self._state = len(sessions) self._now_playing = now_playing diff --git a/homeassistant/components/sensor/serial_pm.py b/homeassistant/components/sensor/serial_pm.py new file mode 100644 index 00000000000..f625affce2c --- /dev/null +++ b/homeassistant/components/sensor/serial_pm.py @@ -0,0 +1,94 @@ +""" +Support for particulate matter sensors connected to a serial port. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.serial_pm/ +""" +import logging +import voluptuous as vol + +from homeassistant.const import CONF_NAME, CONF_PLATFORM +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA + +REQUIREMENTS = ['pmsensor==0.2'] + + +_LOGGER = logging.getLogger(__name__) + +CONF_SERIAL_DEVICE = "serial_device" +CONF_BRAND = "brand" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PLATFORM): 'serial_pm', + vol.Optional(CONF_NAME, default=""): cv.string, + vol.Required(CONF_SERIAL_DEVICE): cv.string, + vol.Required(CONF_BRAND): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the available PM sensors.""" + from pmsensor import serial_data_collector as pm + + try: + coll = pm.PMDataCollector(config.get(CONF_SERIAL_DEVICE), + pm.SUPPORTED_SENSORS[config.get(CONF_BRAND)]) + except KeyError: + _LOGGER.error("Brand %s not supported\n supported brands: %s", + config.get(CONF_BRAND), pm.SUPPORTED_SENSORS.keys()) + return + except OSError as err: + _LOGGER.error("Could not open serial connection to %s (%s)", + config.get(CONF_SERIAL_DEVICE), err) + return + + dev = [] + + for pmname in coll.supported_values(): + if config.get("name") != "": + name = "{} PM{}".format(config.get("name"), pmname) + else: + name = "PM{}".format(pmname) + dev.append(ParticulateMatterSensor(coll, name, pmname)) + + add_devices(dev) + + +class ParticulateMatterSensor(Entity): + """Representation of an Particulate matter sensor.""" + + def __init__(self, pmDataCollector, name, pmname): + """Initialize a new PM sensor.""" + self._name = name + self._pmname = pmname + self._state = None + self._collector = pmDataCollector + + @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 unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return "µg/m³" + + def update(self): + """Read from sensor and update the state.""" + _LOGGER.debug("Reading data from PM sensor") + try: + self._state = self._collector.read_data()[self._pmname] + except KeyError: + _LOGGER.error("Could not read PM%s value", self._pmname) + + def should_poll(self): + """Sensor needs polling.""" + return True diff --git a/homeassistant/components/sensor/temper.py b/homeassistant/components/sensor/temper.py index 5beb49d64e0..fe5ebb17982 100644 --- a/homeassistant/components/sensor/temper.py +++ b/homeassistant/components/sensor/temper.py @@ -21,7 +21,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Setup the Temper sensors.""" from temperusb.temper import TemperHandler - temp_unit = hass.config.temperature_unit + temp_unit = hass.config.units.temperature_unit name = config.get(CONF_NAME, DEVICE_DEFAULT_NAME) temper_devices = TemperHandler().get_devices() add_devices_callback([TemperSensor(dev, temp_unit, name + '_' + str(idx)) diff --git a/homeassistant/components/sensor/vera.py b/homeassistant/components/sensor/vera.py index 537b7847b7e..927c1863cce 100644 --- a/homeassistant/components/sensor/vera.py +++ b/homeassistant/components/sensor/vera.py @@ -51,7 +51,8 @@ class VeraSensor(VeraDevice, Entity): def update(self): """Update the state.""" if self.vera_device.category == "Temperature Sensor": - current_temp = self.vera_device.temperature + self.current_value = self.vera_device.temperature + vera_temp_units = ( self.vera_device.vera_controller.temperature_units) @@ -60,14 +61,6 @@ class VeraSensor(VeraDevice, Entity): else: self._temperature_units = TEMP_CELSIUS - if self.hass: - temp = self.hass.config.temperature( - current_temp, - self._temperature_units) - - current_temp, self._temperature_units = temp - - self.current_value = current_temp elif self.vera_device.category == "Light Sensor": self.current_value = self.vera_device.light elif self.vera_device.category == "Humidity Sensor": diff --git a/homeassistant/components/sensor/worldclock.py b/homeassistant/components/sensor/worldclock.py index 84a58dcd75a..0cfc4598cd0 100644 --- a/homeassistant/components/sensor/worldclock.py +++ b/homeassistant/components/sensor/worldclock.py @@ -34,7 +34,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class WorldClockSensor(Entity): - """Represenatation of a Worldclock sensor.""" + """Representation of a Worldclock sensor.""" def __init__(self, time_zone, name): """Initialize the sensor.""" diff --git a/homeassistant/components/sensor/yweather.py b/homeassistant/components/sensor/yweather.py index ffeb09dc92d..c308d36f50b 100644 --- a/homeassistant/components/sensor/yweather.py +++ b/homeassistant/components/sensor/yweather.py @@ -13,7 +13,7 @@ from homeassistant.const import (CONF_PLATFORM, TEMP_CELSIUS, from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ["yahooweather==0.4"] +REQUIREMENTS = ["yahooweather==0.6"] SENSOR_TYPES = { 'weather_current': ['Current', None], @@ -45,7 +45,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Yahoo! weather sensor.""" from yahooweather import get_woeid, UNIT_C, UNIT_F - unit = hass.config.temperature_unit + unit = hass.config.units.temperature_unit woeid = config.get("woeid", None) forecast = config.get("forecast", 0) diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 80dcc0f54be..901a00a72ef 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -1,3 +1,44 @@ +foursquare: + checkin: + description: Check a user into a Foursquare venue + + fields: + venueId: + description: The Foursquare venue where the user is checking in. [Required] + example: IHR8THISVNU + + eventId: + description: The event the user is checking in to. [Optional] + example: UHR8THISVNT + + shout: + description: A message about your check-in. The maximum length of this field is 140 characters. [Optional] + example: There are crayons! Crayons! + + mentions: + description: Mentions in your check-in. This parameter is a semicolon-delimited list of mentions. A single mention is of the form "start,end,userid", where start is the index of the first character in the shout representing the mention, end is the index of the first character in the shout after the mention, and userid is the userid of the user being mentioned. If userid is prefixed with "fbu-", this indicates a Facebook userid that is being mention. Character indices in shouts are 0-based. [Optional] + example: 5,10,HZXXY3Y;15,20,GZYYZ3Z;25,30,fbu-GZXY13Y + + broadcast: + description: "Who to broadcast this check-in to. Accepts a comma-delimited list of values: private (off the grid) or public (share with friends), facebook share on facebook, twitter share on twitter, followers share with followers (celebrity mode users only), If no valid value is found, the default is public. [Optional]" + example: public,twitter + + ll: + description: Latitude and longitude of the user's location. Only specify this field if you have a GPS or other device reported location for the user at the time of check-in. [Optional] + example: 33.7,44.2 + + llAcc: + description: Accuracy of the user's latitude and longitude, in meters. [Optional] + example: 1 + + alt: + description: Altitude of the user's location, in meters. [Optional] + example: 0 + + altAcc: + description: Vertical accuracy of the user's location, in meters. + example: 1 + persistent_notification: create: description: Show a notification in the frontend @@ -31,3 +72,31 @@ homematic: param: description: Event to send i.e. PRESS_LONG, PRESS_SHORT example: PRESS_LONG + +zwave: + add_node: + description: Add a new node to the zwave network. Refer to OZW.log for details. + + add_node_secure: + description: Add a new node to the zwave network with secure communications. Node must support this, and network key must be set. Refer to OZW.log for details. + + cancel_command: + description: Cancel a running zwave controller command. Use this to exit add_node, if you wasn't going to use it but activated it. + + heal_network: + description: Start a zwave network heal. This might take a while and will slow down the zwave network greatly while it is being processed. Refer to OZW.log for details. + + remove_node: + description: Remove a node from the zwave network. Refer to OZW.log for details. + + start_network: + description: Start the zwave network. This might take a while, depending on how big your zwave network is. + + stop_network: + description: Stop the zwave network, all updates into HASS will stop. + + soft_reset: + description: This will reset the controller without removing its data. Use carefully because not all controllers support this. Refer to controllers manual. + + test_network: + description: This will send test to nodes in the zwave network. This will greatly slow down the zwave network while it is being processed. Refer to OZW.log for details. diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index d222ad1764b..a66b45bc82e 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -32,6 +32,12 @@ CONF_START_CT = 'start_colortemp' CONF_SUNSET_CT = 'sunset_colortemp' CONF_STOP_CT = 'stop_colortemp' CONF_BRIGHTNESS = 'brightness' +CONF_MODE = 'mode' + +MODE_XY = 'xy' +MODE_MIRED = 'mired' +MODE_KELVIN = 'kelvin' +DEFAULT_MODE = MODE_XY PLATFORM_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'flux', @@ -46,7 +52,9 @@ PLATFORM_SCHEMA = vol.Schema({ vol.Optional(CONF_STOP_CT, default=1900): vol.All(vol.Coerce(int), vol.Range(min=1000, max=40000)), vol.Optional(CONF_BRIGHTNESS): - vol.All(vol.Coerce(int), vol.Range(min=0, max=255)) + vol.All(vol.Coerce(int), vol.Range(min=0, max=255)), + vol.Optional(CONF_MODE, default=DEFAULT_MODE): + vol.Any(MODE_XY, MODE_MIRED, MODE_KELVIN) }) @@ -60,6 +68,18 @@ def set_lights_xy(hass, lights, x_val, y_val, brightness): transition=30) +def set_lights_temp(hass, lights, kelvin, mode): + """Set color of array of lights.""" + temp = kelvin + if mode == MODE_MIRED: + temp = 1000000 / kelvin + for light in lights: + if is_on(hass, light): + turn_on(hass, light, + color_temp=int(temp), + transition=30) + + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Flux switches.""" @@ -71,9 +91,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): sunset_colortemp = config.get(CONF_SUNSET_CT) stop_colortemp = config.get(CONF_STOP_CT) brightness = config.get(CONF_BRIGHTNESS) + mode = config.get(CONF_MODE) flux = FluxSwitch(name, hass, False, lights, start_time, stop_time, start_colortemp, sunset_colortemp, stop_colortemp, - brightness) + brightness, mode) add_devices([flux]) def update(call=None): @@ -90,7 +111,7 @@ class FluxSwitch(SwitchDevice): # pylint: disable=too-many-arguments def __init__(self, name, hass, state, lights, start_time, stop_time, start_colortemp, sunset_colortemp, stop_colortemp, - brightness): + brightness, mode): """Initialize the Flux switch.""" self._name = name self.hass = hass @@ -102,6 +123,7 @@ class FluxSwitch(SwitchDevice): self._sunset_colortemp = sunset_colortemp self._stop_colortemp = stop_colortemp self._brightness = brightness + self._mode = mode self.tracker = None @property @@ -141,25 +163,19 @@ class FluxSwitch(SwitchDevice): if start_time < now < sunset: # Daytime + time_state = 'day' temp_range = abs(self._start_colortemp - self._sunset_colortemp) day_length = int(sunset.timestamp() - start_time.timestamp()) seconds_from_start = int(now.timestamp() - start_time.timestamp()) - percentage_of_day_complete = seconds_from_start / day_length - temp_offset = temp_range * percentage_of_day_complete + percentage_complete = seconds_from_start / day_length + temp_offset = temp_range * percentage_complete if self._start_colortemp > self._sunset_colortemp: temp = self._start_colortemp - temp_offset else: temp = self._start_colortemp + temp_offset - x_val, y_val, b_val = color_RGB_to_xy(*temp_to_rgb(temp)) - brightness = self._brightness if self._brightness else b_val - set_lights_xy(self.hass, self._lights, x_val, - y_val, brightness) - _LOGGER.info("Lights updated to x:%s y:%s brightness:%s, %s%%" - " of day cycle complete at %s", x_val, y_val, - brightness, round(percentage_of_day_complete*100), - as_local(now)) else: # Nightime + time_state = 'night' if now < stop_time and now > start_time: now_time = now else: @@ -168,20 +184,28 @@ class FluxSwitch(SwitchDevice): night_length = int(stop_time.timestamp() - sunset.timestamp()) seconds_from_sunset = int(now_time.timestamp() - sunset.timestamp()) - percentage_of_night_complete = seconds_from_sunset / night_length - temp_offset = temp_range * percentage_of_night_complete + 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 else: temp = self._sunset_colortemp + temp_offset + if self._mode == MODE_XY: x_val, y_val, b_val = color_RGB_to_xy(*temp_to_rgb(temp)) brightness = self._brightness if self._brightness else b_val set_lights_xy(self.hass, self._lights, x_val, y_val, brightness) _LOGGER.info("Lights updated to x:%s y:%s brightness:%s, %s%%" - " of night cycle complete at %s", x_val, y_val, - brightness, round(percentage_of_night_complete*100), + " of %s cycle complete at %s", x_val, y_val, + brightness, round( + percentage_complete * 100), time_state, as_local(now)) + else: + set_lights_temp(self.hass, self._lights, temp, self._mode) + _LOGGER.info("Lights updated to temp:%s, %s%%" + " of %s cycle complete at %s", temp, + round(percentage_complete * 100), + time_state, as_local(now)) def find_start_time(self, now): """Return sunrise or start_time if given.""" diff --git a/homeassistant/components/switch/pilight.py b/homeassistant/components/switch/pilight.py new file mode 100644 index 00000000000..47e040ddb67 --- /dev/null +++ b/homeassistant/components/switch/pilight.py @@ -0,0 +1,110 @@ +""" +Support for switching devices via pilight to on and off. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.pilight/ +""" +import logging + +from homeassistant.helpers.config_validation import ensure_list +import homeassistant.components.pilight as pilight +from homeassistant.components.switch import SwitchDevice + +DEPENDENCIES = ['pilight'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup the pilight platform.""" + # Find and return switches controlled by pilight + switches = config.get('switches', {}) + devices = [] + + for dev_name, properties in switches.items(): + devices.append( + PilightSwitch( + hass, + properties.get('name', dev_name), + properties.get('on_code'), + properties.get('off_code'), + ensure_list(properties.get('on_code_receive', False)), + ensure_list(properties.get('off_code_receive', False)))) + + add_devices_callback(devices) + + +class PilightSwitch(SwitchDevice): + """Representation of a pilight switch.""" + + # pylint: disable=too-many-arguments, too-many-instance-attributes + def __init__(self, hass, name, code_on, code_off, + code_on_receive, code_off_receive): + """Initialize the switch.""" + self._hass = hass + self._name = name + self._state = False + self._code_on = code_on + self._code_off = code_off + self._code_on_receive = code_on_receive + self._code_off_receive = code_off_receive + + if any(self._code_on_receive) or any(self._code_off_receive): + hass.bus.listen(pilight.EVENT, self._handle_code) + + @property + def name(self): + """Get the name of the switch.""" + return self._name + + @property + def should_poll(self): + """No polling needed, state set when correct code is received.""" + return False + + @property + def is_on(self): + """Return true if switch is on.""" + return self._state + + def _handle_code(self, call): + """Check if received code by the pilight-daemon. + + If the code matches the receive on / off codes of this switch + the switch state is changed accordingly. + """ + # Check if a on code is defined to turn this switch on + if any(self._code_on_receive): + for on_code in self._code_on_receive: # Loop through codes + # True if on_code is contained in received code dict, not + # all items have to match + if on_code.items() <= call.data.items(): + self.turn_on() + # Call turn on only once, even when more than one on + # code is received + break + + # Check if a off code is defined to turn this switch off + if any(self._code_off_receive): + for off_code in self._code_off_receive: # Loop through codes + # True if off_code is contained in received code dict, not + # all items have to match + if off_code.items() <= call.data.items(): + self.turn_off() + # Call turn off only once, even when more than one off + # code is received + break + + def turn_on(self): + """Turn the switch on by calling pilight.send service with on code.""" + self._hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME, + self._code_on, blocking=True) + self._state = True + self.update_ha_state() + + def turn_off(self): + """Turn the switch on by calling pilight.send service with off code.""" + self._hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME, + self._code_off, blocking=True) + self._state = False + self.update_ha_state() diff --git a/homeassistant/components/thermostat/__init__.py b/homeassistant/components/thermostat/__init__.py index 535a98567d8..09a18b91402 100644 --- a/homeassistant/components/thermostat/__init__.py +++ b/homeassistant/components/thermostat/__init__.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/thermostat/ """ import logging import os +from numbers import Number import voluptuous as vol @@ -13,9 +14,9 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.config import load_yaml_config_file from homeassistant.helpers.entity import Entity -from homeassistant.helpers.temperature import convert from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa import homeassistant.helpers.config_validation as cv +from homeassistant.util.temperature import convert from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_ON, STATE_OFF, STATE_UNKNOWN, TEMP_CELSIUS) @@ -146,10 +147,11 @@ def setup(hass, config): temperature = service.data[ATTR_TEMPERATURE] for thermostat in target_thermostats: - thermostat.set_temperature(convert( - temperature, hass.config.temperature_unit, - thermostat.unit_of_measurement)) + converted_temperature = convert( + temperature, hass.config.units.temperature_unit, + thermostat.unit_of_measurement) + thermostat.set_temperature(converted_temperature) thermostat.update_ha_state(True) hass.services.register( @@ -310,13 +312,13 @@ class ThermostatDevice(Entity): def _convert_for_display(self, temp): """Convert temperature into preferred units for display purposes.""" - if temp is None: - return None + if temp is None or not isinstance(temp, Number): + return temp - value = convert(temp, self.unit_of_measurement, - self.hass.config.temperature_unit) + value = self.hass.config.units.temperature(temp, + self.unit_of_measurement) - if self.hass.config.temperature_unit is TEMP_CELSIUS: + if self.hass.config.units.is_metric: decimal_count = 1 else: # Users of fahrenheit generally expect integer units. diff --git a/homeassistant/components/thermostat/eq3btsmart.py b/homeassistant/components/thermostat/eq3btsmart.py index 17f166a297e..a2aec1b8f60 100644 --- a/homeassistant/components/thermostat/eq3btsmart.py +++ b/homeassistant/components/thermostat/eq3btsmart.py @@ -7,8 +7,8 @@ https://home-assistant.io/components/thermostat.eq3btsmart/ import logging from homeassistant.components.thermostat import ThermostatDevice -from homeassistant.const import TEMP_CELCIUS -from homeassistant.helpers.temperature import convert +from homeassistant.const import TEMP_CELSIUS +from homeassistant.util.temperature import convert REQUIREMENTS = ['bluepy_devices==0.2.0'] @@ -51,7 +51,7 @@ class EQ3BTSmartThermostat(ThermostatDevice): @property def unit_of_measurement(self): """Return the unit of measurement that is used.""" - return TEMP_CELCIUS + return TEMP_CELSIUS @property def current_temperature(self): @@ -76,13 +76,13 @@ class EQ3BTSmartThermostat(ThermostatDevice): @property def min_temp(self): """Return the minimum temperature.""" - return convert(self._thermostat.min_temp, TEMP_CELCIUS, + return convert(self._thermostat.min_temp, TEMP_CELSIUS, self.unit_of_measurement) @property def max_temp(self): """Return the maximum temperature.""" - return convert(self._thermostat.max_temp, TEMP_CELCIUS, + return convert(self._thermostat.max_temp, TEMP_CELSIUS, self.unit_of_measurement) def update(self): diff --git a/homeassistant/components/thermostat/heat_control.py b/homeassistant/components/thermostat/heat_control.py index 3d5190bcc2f..faf4059f891 100644 --- a/homeassistant/components/thermostat/heat_control.py +++ b/homeassistant/components/thermostat/heat_control.py @@ -8,12 +8,11 @@ import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv -import homeassistant.util as util from homeassistant.components import switch from homeassistant.components.thermostat import ( - STATE_HEAT, STATE_IDLE, ThermostatDevice) -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT) + STATE_HEAT, STATE_COOL, STATE_IDLE, ThermostatDevice) +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF +from homeassistant.helpers import condition from homeassistant.helpers.event import track_state_change DEPENDENCIES = ['switch', 'sensor'] @@ -27,6 +26,8 @@ CONF_SENSOR = 'target_sensor' CONF_MIN_TEMP = 'min_temp' CONF_MAX_TEMP = 'max_temp' CONF_TARGET_TEMP = 'target_temp' +CONF_AC_MODE = 'ac_mode' +CONF_MIN_DUR = 'min_cycle_duration' _LOGGER = logging.getLogger(__name__) @@ -38,6 +39,8 @@ PLATFORM_SCHEMA = vol.Schema({ vol.Optional(CONF_MIN_TEMP): vol.Coerce(float), vol.Optional(CONF_MAX_TEMP): vol.Coerce(float), vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float), + vol.Optional(CONF_AC_MODE): vol.Coerce(bool), + vol.Optional(CONF_MIN_DUR): vol.All(cv.time_period, cv.positive_timedelta), }) @@ -49,9 +52,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): min_temp = config.get(CONF_MIN_TEMP) max_temp = config.get(CONF_MAX_TEMP) target_temp = config.get(CONF_TARGET_TEMP) + ac_mode = config.get(CONF_AC_MODE) + min_cycle_duration = config.get(CONF_MIN_DUR) add_devices([HeatControl(hass, name, heater_entity_id, sensor_entity_id, - min_temp, max_temp, target_temp)]) + min_temp, max_temp, target_temp, ac_mode, + min_cycle_duration)]) # pylint: disable=too-many-instance-attributes, abstract-method @@ -60,18 +66,20 @@ class HeatControl(ThermostatDevice): # pylint: disable=too-many-arguments def __init__(self, hass, name, heater_entity_id, sensor_entity_id, - min_temp, max_temp, target_temp): + min_temp, max_temp, target_temp, ac_mode, min_cycle_duration): """Initialize the thermostat.""" self.hass = hass self._name = name self.heater_entity_id = heater_entity_id + self.ac_mode = ac_mode + self.min_cycle_duration = min_cycle_duration self._active = False self._cur_temp = None self._min_temp = min_temp self._max_temp = max_temp self._target_temp = target_temp - self._unit = None + self._unit = hass.config.units.temperature_unit track_state_change(hass, sensor_entity_id, self._sensor_changed) @@ -102,7 +110,12 @@ class HeatControl(ThermostatDevice): @property def operation(self): """Return current operation ie. heat, cool, idle.""" - return STATE_HEAT if self._active and self._is_heating else STATE_IDLE + if self.ac_mode: + cooling = self._active and self._is_device_active + return STATE_COOL if cooling else STATE_IDLE + else: + heating = self._active and self._is_device_active + return STATE_HEAT if heating else STATE_IDLE @property def target_temperature(self): @@ -148,24 +161,11 @@ class HeatControl(ThermostatDevice): """Update thermostat with latest state from sensor.""" unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - if unit not in (TEMP_CELSIUS, TEMP_FAHRENHEIT): - self._cur_temp = None - self._unit = None - _LOGGER.error('Sensor has unsupported unit: %s (allowed: %s, %s)', - unit, TEMP_CELSIUS, TEMP_FAHRENHEIT) - return - - temp = util.convert(state.state, float) - - if temp is None: - self._cur_temp = None - self._unit = None - _LOGGER.error('Unable to parse sensor temperature: %s', - state.state) - return - - self._cur_temp = temp - self._unit = unit + try: + self._cur_temp = self.hass.config.units.temperature( + float(state.state), unit) + except ValueError as ex: + _LOGGER.error('Unable to update from sensor: %s', ex) def _control_heating(self): """Check if we need to turn heating on or off.""" @@ -178,17 +178,38 @@ class HeatControl(ThermostatDevice): if not self._active: return - too_cold = self._target_temp - self._cur_temp > TOL_TEMP - is_heating = self._is_heating + if self.min_cycle_duration: + if self._is_device_active: + current_state = STATE_ON + else: + current_state = STATE_OFF + long_enough = condition.state(self.hass, self.heater_entity_id, + current_state, + self.min_cycle_duration) + if not long_enough: + return - if too_cold and not is_heating: - _LOGGER.info('Turning on heater %s', self.heater_entity_id) - switch.turn_on(self.hass, self.heater_entity_id) - elif not too_cold and is_heating: - _LOGGER.info('Turning off heater %s', self.heater_entity_id) - switch.turn_off(self.hass, self.heater_entity_id) + if self.ac_mode: + too_hot = self._cur_temp - self._target_temp > TOL_TEMP + is_cooling = self._is_device_active + if too_hot and not is_cooling: + _LOGGER.info('Turning on AC %s', self.heater_entity_id) + switch.turn_on(self.hass, self.heater_entity_id) + elif not too_hot and is_cooling: + _LOGGER.info('Turning off AC %s', self.heater_entity_id) + switch.turn_off(self.hass, self.heater_entity_id) + else: + too_cold = self._target_temp - self._cur_temp > TOL_TEMP + is_heating = self._is_device_active + + if too_cold and not is_heating: + _LOGGER.info('Turning on heater %s', self.heater_entity_id) + switch.turn_on(self.hass, self.heater_entity_id) + elif not too_cold and is_heating: + _LOGGER.info('Turning off heater %s', self.heater_entity_id) + switch.turn_off(self.hass, self.heater_entity_id) @property - def _is_heating(self): - """If the heater is currently heating.""" + def _is_device_active(self): + """If the toggleable device is currently active.""" return switch.is_on(self.hass, self.heater_entity_id) diff --git a/homeassistant/components/thermostat/homematic.py b/homeassistant/components/thermostat/homematic.py index 345b8785b42..73901ab61df 100644 --- a/homeassistant/components/thermostat/homematic.py +++ b/homeassistant/components/thermostat/homematic.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/thermostat.homematic/ import logging import homeassistant.components.homematic as homematic from homeassistant.components.thermostat import ThermostatDevice -from homeassistant.helpers.temperature import convert +from homeassistant.util.temperature import convert from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN DEPENDENCIES = ['homematic'] diff --git a/homeassistant/components/thermostat/honeywell.py b/homeassistant/components/thermostat/honeywell.py index f45b07b9fd6..b31dfea0b4b 100644 --- a/homeassistant/components/thermostat/honeywell.py +++ b/homeassistant/components/thermostat/honeywell.py @@ -136,11 +136,20 @@ class RoundThermostat(ThermostatDevice): """Set new target temperature.""" self.device.set_temperature(self._name, temperature) + @property + def operation(self: ThermostatDevice) -> str: + """Get the current operation of the system.""" + return self.device.system_mode + @property def is_away_mode_on(self): """Return true if away mode is on.""" return self._away + def set_hvac_mode(self: ThermostatDevice, hvac_mode: str) -> None: + """Set the HVAC mode for the thermostat.""" + self.device.system_mode = hvac_mode + def turn_away_mode_on(self): """Turn away on. @@ -219,6 +228,11 @@ class HoneywellUSThermostat(ThermostatDevice): else: return self._device.setpoint_heat + @property + def operation(self: ThermostatDevice) -> str: + """Return current operation ie. heat, cool, idle.""" + return self._device.system_mode + def set_temperature(self, temperature): """Set target temperature.""" import somecomfort @@ -244,3 +258,7 @@ class HoneywellUSThermostat(ThermostatDevice): def turn_away_mode_off(self): """Turn away off.""" pass + + def set_hvac_mode(self: ThermostatDevice, hvac_mode: str) -> None: + """Set the system mode (Cool, Heat, etc).""" + self._device.system_mode = hvac_mode diff --git a/homeassistant/components/thermostat/knx.py b/homeassistant/components/thermostat/knx.py index 621830c828e..af8c2af156b 100644 --- a/homeassistant/components/thermostat/knx.py +++ b/homeassistant/components/thermostat/knx.py @@ -43,7 +43,7 @@ class KNXThermostat(KNXMultiAddressDevice, ThermostatDevice): ["temperature", "setpoint"], ["mode"]) - self._unit_of_measurement = TEMP_CELSIUS # KNX always used celcius + self._unit_of_measurement = TEMP_CELSIUS # KNX always used celsius self._away = False # not yet supported self._is_fan_on = False # not yet supported diff --git a/homeassistant/components/thermostat/proliphix.py b/homeassistant/components/thermostat/proliphix.py index bf5c61d2be6..e54a5a4aa11 100644 --- a/homeassistant/components/thermostat/proliphix.py +++ b/homeassistant/components/thermostat/proliphix.py @@ -9,7 +9,7 @@ from homeassistant.components.thermostat import ( from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, TEMP_FAHRENHEIT) -REQUIREMENTS = ['proliphix==0.1.0'] +REQUIREMENTS = ['proliphix==0.3.1'] def setup_platform(hass, config, add_devices, discovery_info=None): @@ -72,7 +72,7 @@ class ProliphixThermostat(ThermostatDevice): @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._pdp.setback_heat + return self._pdp.setback @property def operation(self): @@ -87,4 +87,4 @@ class ProliphixThermostat(ThermostatDevice): def set_temperature(self, temperature): """Set new target temperature.""" - self._pdp.setback_heat = temperature + self._pdp.setback = temperature diff --git a/homeassistant/components/zwave.py b/homeassistant/components/zwave.py index e280f5adcf9..a60319188ce 100644 --- a/homeassistant/components/zwave.py +++ b/homeassistant/components/zwave.py @@ -11,7 +11,7 @@ from pprint import pprint from homeassistant.helpers import discovery from homeassistant.const import ( - ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, ATTR_LOCATION, + ATTR_BATTERY_LEVEL, ATTR_LOCATION, ATTR_ENTITY_ID, CONF_CUSTOMIZE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers.event import track_time_change @@ -32,13 +32,21 @@ DEFAULT_CONF_AUTOHEAL = True NETWORK_READY_WAIT_SECS = 30 SERVICE_ADD_NODE = "add_node" +SERVICE_ADD_NODE_SECURE = "add_node_secure" SERVICE_REMOVE_NODE = "remove_node" +SERVICE_CANCEL_COMMAND = "cancel_command" SERVICE_HEAL_NETWORK = "heal_network" SERVICE_SOFT_RESET = "soft_reset" SERVICE_TEST_NETWORK = "test_network" +SERVICE_STOP_NETWORK = "stop_network" +SERVICE_START_NETWORK = "start_network" EVENT_SCENE_ACTIVATED = "zwave.scene_activated" EVENT_NODE_EVENT = "zwave.node_event" +EVENT_NETWORK_READY = "zwave.network_ready" +EVENT_NETWORK_COMPLETE = "zwave.network_complete" +EVENT_NETWORK_START = "zwave.network_start" +EVENT_NETWORK_STOP = "zwave.network_stop" COMMAND_CLASS_WHATEVER = None COMMAND_CLASS_SENSOR_MULTILEVEL = 49 @@ -51,16 +59,17 @@ COMMAND_CLASS_SWITCH_MULTILEVEL = 38 COMMAND_CLASS_DOOR_LOCK = 98 COMMAND_CLASS_THERMOSTAT_SETPOINT = 67 COMMAND_CLASS_THERMOSTAT_FAN_MODE = 68 +COMMAND_CLASS_BARRIER_OPERATOR = 102 COMMAND_CLASS_BATTERY = 128 COMMAND_CLASS_SENSOR_ALARM = 156 GENERIC_COMMAND_CLASS_WHATEVER = None GENERIC_COMMAND_CLASS_REMOTE_CONTROLLER = 1 GENERIC_COMMAND_CLASS_NOTIFICATION = 7 -GENERIC_COMMAND_CLASS_REMOTE_SWITCH = 12 GENERIC_COMMAND_CLASS_REPEATER_SLAVE = 15 -GENERIC_COMMAND_CLASS_MULTILEVEL_SWITCH = 17 GENERIC_COMMAND_CLASS_BINARY_SWITCH = 16 +GENERIC_COMMAND_CLASS_MULTILEVEL_SWITCH = 17 +GENERIC_COMMAND_CLASS_REMOTE_SWITCH = 18 GENERIC_COMMAND_CLASS_WALL_CONTROLLER = 24 GENERIC_COMMAND_CLASS_ENTRY_CONTROL = 64 GENERIC_COMMAND_CLASS_BINARY_SENSOR = 32 @@ -104,7 +113,8 @@ DISCOVERY_COMPONENTS = [ TYPE_WHATEVER, GENRE_USER), ('light', - [GENERIC_COMMAND_CLASS_MULTILEVEL_SWITCH], + [GENERIC_COMMAND_CLASS_MULTILEVEL_SWITCH, + GENERIC_COMMAND_CLASS_REMOTE_SWITCH], [SPECIFIC_DEVICE_CLASS_MULTILEVEL_POWER_SWITCH, SPECIFIC_DEVICE_CLASS_MULTILEVEL_SCENE, SPECIFIC_DEVICE_CLASS_NOT_USED], @@ -173,7 +183,8 @@ DISCOVERY_COMPONENTS = [ [GENERIC_COMMAND_CLASS_ENTRY_CONTROL], [SPECIFIC_DEVICE_CLASS_SECURE_BARRIER_ADD_ON, SPECIFIC_DEVICE_CLASS_SECURE_DOOR], - [COMMAND_CLASS_SWITCH_BINARY], + [COMMAND_CLASS_SWITCH_BINARY, + COMMAND_CLASS_BARRIER_OPERATOR], TYPE_BOOL, GENRE_USER) ] @@ -306,7 +317,9 @@ def setup(hass, config): if value and signal in (ZWaveNetwork.SIGNAL_VALUE_CHANGED, ZWaveNetwork.SIGNAL_VALUE_ADDED, ZWaveNetwork.SIGNAL_SCENE_EVENT, - ZWaveNetwork.SIGNAL_NODE_EVENT): + ZWaveNetwork.SIGNAL_NODE_EVENT, + ZWaveNetwork.SIGNAL_AWAKE_NODES_QUERIED, + ZWaveNetwork.SIGNAL_ALL_NODES_QUERIED): pprint(_obj_to_dict(value)) print("") @@ -326,20 +339,20 @@ def setup(hass, config): component, node.node_id) if node.generic not in generic_device_class and \ None not in generic_device_class: - _LOGGER.debug("node.generic %s not None and in \ - generic_device_class %s", + _LOGGER.debug("node.generic %s not None and in " + "generic_device_class %s", node.generic, generic_device_class) continue if node.specific not in specific_device_class and \ None not in specific_device_class: - _LOGGER.debug("node.specific %s is not None and in \ - specific_device_class %s", node.specific, + _LOGGER.debug("node.specific %s is not None and in " + "specific_device_class %s", node.specific, specific_device_class) continue if value.command_class not in command_class and \ None not in command_class: - _LOGGER.debug("value.command_class %s is not None \ - and in command_class %s", + _LOGGER.debug("value.command_class %s is not None " + "and in command_class %s", value.command_class, command_class) continue if value_type != value.type and value_type is not None: @@ -352,10 +365,10 @@ def setup(hass, config): continue # Configure node - _LOGGER.debug("Adding Node_id=%s Generic_command_class=%s, \ - Specific_command_class=%s, \ - Command_class=%s, Value type=%s, \ - Genre=%s", node.node_id, + _LOGGER.debug("Adding Node_id=%s Generic_command_class=%s, " + "Specific_command_class=%s, " + "Command_class=%s, Value type=%s, " + "Genre=%s", node.node_id, node.generic, node.specific, value.command_class, value.type, value.genre) @@ -389,21 +402,50 @@ def setup(hass, config): ATTR_BASIC_LEVEL: value }) + def network_ready(): + """Called when all awake nodes have been queried.""" + _LOGGER.info("Zwave network is ready for use. All awake nodes" + " have been queried. Sleeping nodes will be" + " queried when they awake.") + hass.bus.fire(EVENT_NETWORK_READY) + + def network_complete(): + """Called when all nodes on network have been queried.""" + _LOGGER.info("Zwave network is complete. All nodes on the network" + " have been queried") + hass.bus.fire(EVENT_NETWORK_COMPLETE) + dispatcher.connect( value_added, ZWaveNetwork.SIGNAL_VALUE_ADDED, weak=False) dispatcher.connect( scene_activated, ZWaveNetwork.SIGNAL_SCENE_EVENT, weak=False) dispatcher.connect( node_event_activated, ZWaveNetwork.SIGNAL_NODE_EVENT, weak=False) + dispatcher.connect( + network_ready, ZWaveNetwork.SIGNAL_AWAKE_NODES_QUERIED, weak=False) + dispatcher.connect( + network_complete, ZWaveNetwork.SIGNAL_ALL_NODES_QUERIED, weak=False) def add_node(service): """Switch into inclusion mode.""" + _LOGGER.info("Zwave add_node have been initialized.") NETWORK.controller.add_node() + def add_node_secure(service): + """Switch into secure inclusion mode.""" + _LOGGER.info("Zwave add_node_secure have been initialized.") + NETWORK.controller.add_node(True) + def remove_node(service): """Switch into exclusion mode.""" + _LOGGER.info("Zwave remove_node have been initialized.") NETWORK.controller.remove_node() + def cancel_command(service): + """Cancel a running controller command.""" + _LOGGER.info("Cancel running ZWave command.") + NETWORK.controller.cancel_command() + def heal_network(service): """Heal the network.""" _LOGGER.info("ZWave heal running.") @@ -411,19 +453,25 @@ def setup(hass, config): def soft_reset(service): """Soft reset the controller.""" + _LOGGER.info("Zwave soft_reset have been initialized.") NETWORK.controller.soft_reset() def test_network(service): """Test the network by sending commands to all the nodes.""" + _LOGGER.info("Zwave test_network have been initialized.") NETWORK.test() - def stop_zwave(event): - """Stop Z-Wave.""" + def stop_zwave(_service_or_event): + """Stop Z-Wave network.""" + _LOGGER.info("Stopping ZWave network.") NETWORK.stop() + hass.bus.fire(EVENT_NETWORK_STOP) - def start_zwave(event): - """Startup Z-Wave.""" + def start_zwave(_service_or_event): + """Startup Z-Wave network.""" + _LOGGER.info("Starting ZWave network.") NETWORK.start() + hass.bus.fire(EVENT_NETWORK_START) # Need to be in STATE_AWAKED before talking to nodes. # Wait up to NETWORK_READY_WAIT_SECS seconds for the zwave network @@ -452,13 +500,17 @@ def setup(hass, config): hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_zwave) - # Register add / remove node services for Z-Wave sticks without - # hardware inclusion button + # Register node services for Z-Wave network hass.services.register(DOMAIN, SERVICE_ADD_NODE, add_node) + hass.services.register(DOMAIN, SERVICE_ADD_NODE_SECURE, + add_node_secure) hass.services.register(DOMAIN, SERVICE_REMOVE_NODE, remove_node) + hass.services.register(DOMAIN, SERVICE_CANCEL_COMMAND, cancel_command) hass.services.register(DOMAIN, SERVICE_HEAL_NETWORK, heal_network) hass.services.register(DOMAIN, SERVICE_SOFT_RESET, soft_reset) hass.services.register(DOMAIN, SERVICE_TEST_NETWORK, test_network) + hass.services.register(DOMAIN, SERVICE_STOP_NETWORK, stop_zwave) + hass.services.register(DOMAIN, SERVICE_START_NETWORK, start_zwave) # Setup autoheal if autoheal: diff --git a/homeassistant/config.py b/homeassistant/config.py index 1061bd4f5ec..65ed44bef83 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -4,17 +4,23 @@ import os import shutil from types import MappingProxyType +# pylint: disable=unused-import +from typing import Any, Tuple # NOQA + import voluptuous as vol from homeassistant.const import ( - CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_TEMPERATURE_UNIT, - CONF_TIME_ZONE, CONF_CUSTOMIZE, CONF_ELEVATION, TEMP_FAHRENHEIT, - TEMP_CELSIUS, __version__) + CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_UNIT_SYSTEM, + CONF_TIME_ZONE, CONF_CUSTOMIZE, CONF_ELEVATION, CONF_UNIT_SYSTEM_METRIC, + CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT, TEMP_CELSIUS, + __version__) +from homeassistant.core import valid_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.util.yaml import load_yaml import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import valid_entity_id, set_customize +from homeassistant.helpers.entity import set_customize from homeassistant.util import dt as date_util, location as loc_util +from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM _LOGGER = logging.getLogger(__name__) @@ -30,10 +36,12 @@ DEFAULT_CORE_CONFIG = ( ' the sun rises and sets'), (CONF_LONGITUDE, 0, 'longitude', None), (CONF_ELEVATION, 0, None, 'Impacts weather/sunrise data'), - (CONF_TEMPERATURE_UNIT, 'C', None, 'C for Celsius, F for Fahrenheit'), + (CONF_UNIT_SYSTEM, CONF_UNIT_SYSTEM_METRIC, None, + '{} for Metric, {} for Imperial'.format(CONF_UNIT_SYSTEM_METRIC, + 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'), -) +) # type: Tuple[Tuple[str, Any, Any, str], ...] DEFAULT_CONFIG = """ # Show links to resources in log and frontend introduction: @@ -88,7 +96,8 @@ CORE_CONFIG_SCHEMA = vol.Schema({ CONF_LATITUDE: cv.latitude, CONF_LONGITUDE: cv.longitude, CONF_ELEVATION: vol.Coerce(int), - CONF_TEMPERATURE_UNIT: cv.temperature_unit, + vol.Optional(CONF_TEMPERATURE_UNIT): cv.temperature_unit, + CONF_UNIT_SYSTEM: cv.unit_system, CONF_TIME_ZONE: cv.time_zone, vol.Required(CONF_CUSTOMIZE, default=MappingProxyType({})): _valid_customize, @@ -131,8 +140,10 @@ def create_default_config(config_dir, detect_location=True): location_info = detect_location and loc_util.detect_location_info() if location_info: - if location_info.use_fahrenheit: - info[CONF_TEMPERATURE_UNIT] = 'F' + if location_info.use_metric: + info[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_METRIC + else: + info[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_IMPERIAL for attr, default, prop, _ in DEFAULT_CORE_CONFIG: if prop is None: @@ -244,18 +255,30 @@ def process_ha_core_config(hass, config): set_customize(config.get(CONF_CUSTOMIZE) or {}) - if CONF_TEMPERATURE_UNIT in config: - hac.temperature_unit = config[CONF_TEMPERATURE_UNIT] + if CONF_UNIT_SYSTEM in config: + if config[CONF_UNIT_SYSTEM] == CONF_UNIT_SYSTEM_IMPERIAL: + hac.units = IMPERIAL_SYSTEM + else: + hac.units = METRIC_SYSTEM + elif CONF_TEMPERATURE_UNIT in config: + unit = config[CONF_TEMPERATURE_UNIT] + if unit == TEMP_CELSIUS: + hac.units = METRIC_SYSTEM + else: + hac.units = IMPERIAL_SYSTEM + _LOGGER.warning("Found deprecated temperature unit in core config, " + "expected unit system. Replace 'temperature: %s' with " + "'unit_system: %s'", unit, hac.units.name) # Shortcut if no auto-detection necessary - if None not in (hac.latitude, hac.longitude, hac.temperature_unit, + if None not in (hac.latitude, hac.longitude, hac.units, hac.time_zone, hac.elevation): return discovered = [] # If we miss some of the needed values, auto detect them - if None in (hac.latitude, hac.longitude, hac.temperature_unit, + if None in (hac.latitude, hac.longitude, hac.units, hac.time_zone): info = loc_util.detect_location_info() @@ -264,18 +287,13 @@ def process_ha_core_config(hass, config): return if hac.latitude is None and hac.longitude is None: - hac.latitude = info.latitude - hac.longitude = info.longitude + hac.latitude, hac.longitude = (info.latitude, info.longitude) discovered.append(('latitude', hac.latitude)) discovered.append(('longitude', hac.longitude)) - if hac.temperature_unit is None: - if info.use_fahrenheit: - hac.temperature_unit = TEMP_FAHRENHEIT - discovered.append(('temperature_unit', 'F')) - else: - hac.temperature_unit = TEMP_CELSIUS - discovered.append(('temperature_unit', 'C')) + if hac.units is None: + hac.units = METRIC_SYSTEM if info.use_metric else IMPERIAL_SYSTEM + discovered.append((CONF_UNIT_SYSTEM, hac.units.name)) if hac.location_name is None: hac.location_name = info.city diff --git a/homeassistant/const.py b/homeassistant/const.py index 64ce733f4ec..7161f319b30 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" -__version__ = "0.25.2" +__version__ = "0.26.0" REQUIRED_PYTHON_VER = (3, 4) PLATFORM_FORMAT = '{}.{}' @@ -47,6 +47,7 @@ CONF_PORT = 'port' CONF_SCAN_INTERVAL = 'scan_interval' CONF_STATE = 'state' CONF_TEMPERATURE_UNIT = 'temperature_unit' +CONF_UNIT_SYSTEM = 'unit_system' CONF_TIME_ZONE = 'time_zone' CONF_USERNAME = 'username' CONF_VALUE_TEMPLATE = 'value_template' @@ -112,12 +113,38 @@ ATTR_ICON = "icon" # The unit of measurement if applicable ATTR_UNIT_OF_MEASUREMENT = "unit_of_measurement" +CONF_UNIT_SYSTEM_METRIC = 'metric' # type: str +CONF_UNIT_SYSTEM_IMPERIAL = 'imperial' # type: str + # Temperature attribute ATTR_TEMPERATURE = "temperature" -TEMP_CELCIUS = "°C" TEMP_CELSIUS = "°C" TEMP_FAHRENHEIT = "°F" +# Length units +LENGTH_CENTIMETERS = "cm" # type: str +LENGTH_METERS = "m" # type: str +LENGTH_KILOMETERS = "km" # type: str + +LENGTH_INCHES = "in" # type: str +LENGTH_FEET = "ft" # type: str +LENGTH_YARD = "yd" # type: str +LENGTH_MILES = "mi" # type: str + +# Volume units +VOLUME_LITERS = "L" # type: str +VOLUME_MILLILITERS = "mL" # type: str + +VOLUME_GALLONS = "gal" # type: str +VOLUME_FLUID_OUNCE = "fl. oz." # type: str + +# Mass units +MASS_GRAMS = "g" # type: str +MASS_KILOGRAMS = "kg" # type: str + +MASS_OUNCES = "oz" # type: str +MASS_POUNDS = "lb" # type: str + # Contains the information that is discovered ATTR_DISCOVERED = "discovered" @@ -236,7 +263,8 @@ HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN = "Access-Control-Allow-Origin" HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS = "Access-Control-Allow-Headers" ALLOWED_CORS_HEADERS = [HTTP_HEADER_ORIGIN, HTTP_HEADER_ACCEPT, - HTTP_HEADER_X_REQUESTED_WITH, HTTP_HEADER_CONTENT_TYPE] + HTTP_HEADER_X_REQUESTED_WITH, HTTP_HEADER_CONTENT_TYPE, + HTTP_HEADER_HA_AUTH] CONTENT_TYPE_JSON = "application/json" CONTENT_TYPE_MULTIPART = 'multipart/x-mixed-replace; boundary={}' @@ -244,3 +272,10 @@ CONTENT_TYPE_TEXT_PLAIN = 'text/plain' # The exit code to send to request a restart RESTART_EXIT_CODE = 100 + +UNIT_NOT_RECOGNIZED_TEMPLATE = '{} is not a recognized {} unit.' # type: str + +LENGTH = 'length' # type: str +MASS = 'mass' # type: str +VOLUME = 'volume' # type: str +TEMPERATURE = 'temperature' # type: str diff --git a/homeassistant/core.py b/homeassistant/core.py index 7ddf5a6c10f..ccd8a971f61 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -9,30 +9,31 @@ import enum import functools as ft import logging import os +import re import signal import threading import time from types import MappingProxyType -from typing import Any, Callable -import voluptuous as vol +# pylint: disable=unused-import +from typing import Optional, Any, Callable, List # NOQA + +import voluptuous as vol +from voluptuous.humanize import humanize_error -import homeassistant.helpers.temperature as temp_helper -import homeassistant.util as util -import homeassistant.util.dt as dt_util -import homeassistant.util.location as location -from homeassistant.config import get_default_config_dir from homeassistant.const import ( ATTR_DOMAIN, ATTR_FRIENDLY_NAME, ATTR_NOW, ATTR_SERVICE, ATTR_SERVICE_CALL_ID, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_SERVICE_EXECUTED, EVENT_SERVICE_REGISTERED, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL, RESTART_EXIT_CODE, - SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, TEMP_CELSIUS, - TEMP_FAHRENHEIT, __version__) + SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, __version__) from homeassistant.exceptions import ( HomeAssistantError, InvalidEntityFormatError) -from homeassistant.helpers.entity import split_entity_id, valid_entity_id +import homeassistant.util as util +import homeassistant.util.dt as dt_util +import homeassistant.util.location as location +from homeassistant.util.unit_system import UnitSystem, METRIC_SYSTEM # NOQA DOMAIN = "homeassistant" @@ -47,9 +48,22 @@ SERVICE_CALL_LIMIT = 10 # seconds # will be added for each component that polls devices. MIN_WORKER_THREAD = 2 +# Pattern for validating entity IDs (format: .) +ENTITY_ID_PATTERN = re.compile(r"^(\w+)\.(\w+)$") + _LOGGER = logging.getLogger(__name__) +def split_entity_id(entity_id: str) -> List[str]: + """Split a state entity_id into domain, object_id.""" + return entity_id.split(".", 1) + + +def valid_entity_id(entity_id: str) -> bool: + """Test if an entity ID is a valid format.""" + return ENTITY_ID_PATTERN.match(entity_id) is not None + + class CoreState(enum.Enum): """Represent the current state of Home Assistant.""" @@ -95,7 +109,7 @@ class HomeAssistant(object): self.bus = EventBus(pool) self.services = ServiceRegistry(self.bus, self.add_job) self.states = StateMachine(self.bus) - self.config = Config() + self.config = Config() # type: Config self.state = CoreState.not_running @property @@ -558,7 +572,8 @@ class Service(object): self.func(call) except vol.MultipleInvalid as ex: _LOGGER.error('Invalid service data for %s.%s: %s', - call.domain, call.service, ex) + call.domain, call.service, + humanize_error(call.data, ex)) # pylint: disable=too-few-public-methods @@ -569,8 +584,8 @@ class ServiceCall(object): def __init__(self, domain, service, data=None, call_id=None): """Initialize a service call.""" - self.domain = domain - self.service = service + self.domain = domain.lower() + self.service = service.lower() self.data = data or {} self.call_id = call_id @@ -605,7 +620,7 @@ class ServiceRegistry(object): def has_service(self, domain, service): """Test if specified service exists.""" - return service in self._services.get(domain, []) + return service.lower() in self._services.get(domain.lower(), []) # pylint: disable=too-many-arguments def register(self, domain, service, service_func, description=None, @@ -618,6 +633,8 @@ class ServiceRegistry(object): Schema is called to coerce and validate the service data. """ + domain = domain.lower() + service = service.lower() description = description or {} service_obj = Service(service_func, description.get('description'), description.get('fields', {}), schema) @@ -651,8 +668,8 @@ class ServiceRegistry(object): call_id = self._generate_unique_id() event_data = { - ATTR_DOMAIN: domain, - ATTR_SERVICE: service, + ATTR_DOMAIN: domain.lower(), + ATTR_SERVICE: service.lower(), ATTR_SERVICE_DATA: service_data, ATTR_SERVICE_CALL_ID: call_id, } @@ -678,11 +695,14 @@ class ServiceRegistry(object): def _event_to_service_call(self, event): """Callback for SERVICE_CALLED events from the event bus.""" service_data = event.data.get(ATTR_SERVICE_DATA) - domain = event.data.get(ATTR_DOMAIN) - service = event.data.get(ATTR_SERVICE) + domain = event.data.get(ATTR_DOMAIN).lower() + service = event.data.get(ATTR_SERVICE).lower() call_id = event.data.get(ATTR_SERVICE_CALL_ID) if not self.has_service(domain, service): + if event.origin == EventOrigin.local: + _LOGGER.warning('Unable to find service %s/%s', + domain, service) return service_handler = self._services[domain][service] @@ -712,15 +732,15 @@ class Config(object): # pylint: disable=too-many-instance-attributes def __init__(self): """Initialize a new config object.""" - self.latitude = None - self.longitude = None - self.elevation = None - self.temperature_unit = None - self.location_name = None - self.time_zone = None + self.latitude = None # type: Optional[float] + self.longitude = None # type: Optional[float] + self.elevation = None # type: Optional[int] + self.location_name = None # type: Optional[str] + self.time_zone = None # type: Optional[str] + self.units = METRIC_SYSTEM # type: UnitSystem # If True, pip install is skipped for requirements on startup - self.skip_pip = False + self.skip_pip = False # type: bool # List of loaded components self.components = [] @@ -729,31 +749,19 @@ class Config(object): self.api = None # Directory that holds the configuration - self.config_dir = get_default_config_dir() + self.config_dir = None - def distance(self, lat, lon): - """Calculate distance from Home Assistant in meters.""" - return location.distance(self.latitude, self.longitude, lat, lon) + def distance(self: object, lat: float, lon: float) -> float: + """Calculate distance from Home Assistant.""" + return self.units.length( + location.distance(self.latitude, self.longitude, lat, lon), 'm') def path(self, *path): """Generate path to the file within the config dir.""" + if self.config_dir is None: + raise HomeAssistantError("config_dir is not set") return os.path.join(self.config_dir, *path) - def temperature(self, value, unit): - """Convert temperature to user preferred unit if set.""" - if not (unit in (TEMP_CELSIUS, TEMP_FAHRENHEIT) and - self.temperature_unit and unit != self.temperature_unit): - return value, unit - - try: - temp = float(value) - except ValueError: # Could not convert value to float - return value, unit - - return ( - round(temp_helper.convert(temp, unit, self.temperature_unit), 1), - self.temperature_unit) - def as_dict(self): """Create a dict representation of this dict.""" time_zone = self.time_zone or dt_util.UTC @@ -761,7 +769,7 @@ class Config(object): return { 'latitude': self.latitude, 'longitude': self.longitude, - 'temperature_unit': self.temperature_unit, + 'unit_system': self.units.as_dict(), 'location_name': self.location_name, 'time_zone': time_zone.zone, 'components': self.components, diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index e4335e2f2e4..f2c3585ec8b 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -3,6 +3,9 @@ from datetime import timedelta import logging import sys +from homeassistant.helpers.typing import ConfigType + +from homeassistant.core import HomeAssistant from homeassistant.components import ( zone as zone_cmp, sun as sun_cmp) from homeassistant.const import ( @@ -21,7 +24,7 @@ FROM_CONFIG_FORMAT = '{}_from_config' _LOGGER = logging.getLogger(__name__) -def from_config(config, config_validation=True): +def from_config(config: ConfigType, config_validation: bool=True): """Turn a condition configuration into a method.""" factory = getattr( sys.modules[__name__], @@ -34,13 +37,14 @@ def from_config(config, config_validation=True): return factory(config, config_validation) -def and_from_config(config, config_validation=True): +def and_from_config(config: ConfigType, config_validation: bool=True): """Create multi condition matcher using 'AND'.""" if config_validation: config = cv.AND_CONDITION_SCHEMA(config) checks = [from_config(entry) for entry in config['conditions']] - def if_and_condition(hass, variables=None): + def if_and_condition(hass: HomeAssistant, + variables=None) -> bool: """Test and condition.""" for check in checks: try: @@ -55,13 +59,14 @@ def and_from_config(config, config_validation=True): return if_and_condition -def or_from_config(config, config_validation=True): +def or_from_config(config: ConfigType, config_validation: bool=True): """Create multi condition matcher using 'OR'.""" if config_validation: config = cv.OR_CONDITION_SCHEMA(config) checks = [from_config(entry) for entry in config['conditions']] - def if_or_condition(hass, variables=None): + def if_or_condition(hass: HomeAssistant, + variables=None) -> bool: """Test and condition.""" for check in checks: try: @@ -76,8 +81,8 @@ def or_from_config(config, config_validation=True): # pylint: disable=too-many-arguments -def numeric_state(hass, entity, below=None, above=None, value_template=None, - variables=None): +def numeric_state(hass: HomeAssistant, entity, below=None, above=None, + value_template=None, variables=None): """Test a numeric state condition.""" if isinstance(entity, str): entity = hass.states.get(entity) @@ -93,7 +98,7 @@ def numeric_state(hass, entity, below=None, above=None, value_template=None, try: value = render(hass, value_template, variables) except TemplateError as ex: - _LOGGER.error(ex) + _LOGGER.error("Template error: %s", ex) return False try: diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 1dc0cc26b5c..91a05b37b5d 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1,6 +1,8 @@ """Helpers for config validation using voluptuous.""" from datetime import timedelta +from typing import Any, Union, TypeVar, Callable, Sequence, List, Dict + import jinja2 import voluptuous as vol @@ -9,8 +11,8 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_SCAN_INTERVAL, TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_ALIAS, CONF_ENTITY_ID, CONF_VALUE_TEMPLATE, WEEKDAYS, CONF_CONDITION, CONF_BELOW, CONF_ABOVE, SUN_EVENT_SUNSET, - SUN_EVENT_SUNRISE) -from homeassistant.helpers.entity import valid_entity_id + SUN_EVENT_SUNRISE, CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC) +from homeassistant.core import valid_entity_id import homeassistant.util.dt as dt_util from homeassistant.util import slugify @@ -28,12 +30,15 @@ longitude = vol.All(vol.Coerce(float), vol.Range(min=-180, max=180), msg='invalid longitude') sun_event = vol.All(vol.Lower, vol.Any(SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE)) +# typing typevar +T = TypeVar('T') + # Adapted from: # https://github.com/alecthomas/voluptuous/issues/115#issuecomment-144464666 -def has_at_least_one_key(*keys): +def has_at_least_one_key(*keys: str) -> Callable: """Validator that at least one key exists.""" - def validate(obj): + def validate(obj: Dict) -> Dict: """Test keys exist in dict.""" if not isinstance(obj, dict): raise vol.Invalid('expected dictionary') @@ -46,7 +51,7 @@ def has_at_least_one_key(*keys): return validate -def boolean(value): +def boolean(value: Any) -> bool: """Validate and coerce a boolean value.""" if isinstance(value, str): value = value.lower() @@ -63,12 +68,12 @@ def isfile(value): return vol.IsFile('not a file')(value) -def ensure_list(value): +def ensure_list(value: Union[T, Sequence[T]]) -> List[T]: """Wrap value in list if it is not one.""" return value if isinstance(value, list) else [value] -def entity_id(value): +def entity_id(value: Any) -> str: """Validate Entity ID.""" value = string(value).lower() if valid_entity_id(value): @@ -76,7 +81,7 @@ def entity_id(value): raise vol.Invalid('Entity ID {} is an invalid entity id'.format(value)) -def entity_ids(value): +def entity_ids(value: Union[str, Sequence]) -> List[str]: """Validate Entity IDs.""" if value is None: raise vol.Invalid('Entity IDs can not be None') @@ -109,7 +114,7 @@ time_period_dict = vol.All( lambda value: timedelta(**value)) -def time_period_str(value): +def time_period_str(value: str) -> timedelta: """Validate and transform time offset.""" if isinstance(value, int): raise vol.Invalid('Make sure you wrap time values in quotes') @@ -147,23 +152,6 @@ def time_period_str(value): time_period = vol.Any(time_period_str, timedelta, time_period_dict) -def log_exception(logger, ex, domain, config): - """Generate log exception for config validation.""" - message = 'Invalid config for [{}]: '.format(domain) - if 'extra keys not allowed' in ex.error_message: - message += '[{}] is an invalid option for [{}]. Check: {}->{}.'\ - .format(ex.path[-1], domain, domain, - '->'.join('%s' % m for m in ex.path)) - else: - message += str(ex) - - if hasattr(config, '__line__'): - message += " (See {}:{})".format(config.__config_file__, - config.__line__ or '?') - - logger.error(message) - - def match_all(value): """Validator that matches all values.""" return value @@ -182,7 +170,7 @@ def platform_validator(domain): return validator -def positive_timedelta(value): +def positive_timedelta(value: timedelta) -> timedelta: """Validate timedelta is positive.""" if value < timedelta(0): raise vol.Invalid('Time period should be positive') @@ -209,14 +197,14 @@ def slug(value): raise vol.Invalid('invalid slug {} (try {})'.format(value, slg)) -def string(value): +def string(value: Any) -> str: """Coerce value to string, except for None.""" if value is not None: return str(value) raise vol.Invalid('string value is None') -def temperature_unit(value): +def temperature_unit(value) -> str: """Validate and transform temperature unit.""" value = str(value).upper() if value == 'C': @@ -226,10 +214,16 @@ def temperature_unit(value): raise vol.Invalid('invalid temperature unit (expected C or F)') +unit_system = vol.All(vol.Lower, vol.Any(CONF_UNIT_SYSTEM_METRIC, + CONF_UNIT_SYSTEM_IMPERIAL)) + + def template(value): """Validate a jinja2 template.""" if value is None: raise vol.Invalid('template value is None') + if isinstance(value, (list, dict)): + raise vol.Invalid('template value should be a string') value = str(value) try: diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 9968ad3df4a..61cda43d431 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1,6 +1,5 @@ """An abstract class for entities.""" import logging -import re from typing import Any, Optional, List, Dict @@ -9,25 +8,19 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, DEVICE_DEFAULT_NAME, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_ENTITY_PICTURE) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import NoEntitySpecifiedError from homeassistant.util import ensure_unique_string, slugify -# pylint: disable=using-constant-test,unused-import -if False: - from homeassistant.core import HomeAssistant # NOQA - # Entity attributes that we will overwrite _OVERWRITE = {} # type: Dict[str, Any] _LOGGER = logging.getLogger(__name__) -# Pattern for validating entity IDs (format: .) -ENTITY_ID_PATTERN = re.compile(r"^(\w+)\.(\w+)$") - def generate_entity_id(entity_id_format: str, name: Optional[str], current_ids: Optional[List[str]]=None, - hass: 'Optional[HomeAssistant]'=None) -> str: + hass: Optional[HomeAssistant]=None) -> str: """Generate a unique entity ID based on given entity IDs or used IDs.""" name = (name or DEVICE_DEFAULT_NAME).lower() if current_ids is None: @@ -47,16 +40,6 @@ def set_customize(customize: Dict[str, Any]) -> None: _OVERWRITE = {key.lower(): val for key, val in customize.items()} -def split_entity_id(entity_id: str) -> List[str]: - """Split a state entity_id into domain, object_id.""" - return entity_id.split(".", 1) - - -def valid_entity_id(entity_id: str) -> bool: - """Test if an entity ID is a valid format.""" - return ENTITY_ID_PATTERN.match(entity_id) is not None - - class Entity(object): """An abstract class for Home Assistant entities.""" @@ -199,13 +182,15 @@ class Entity(object): attr.pop(ATTR_HIDDEN) # Convert temperature if we detect one - if attr.get(ATTR_UNIT_OF_MEASUREMENT) in (TEMP_CELSIUS, - TEMP_FAHRENHEIT): - - state, attr[ATTR_UNIT_OF_MEASUREMENT] = \ - self.hass.config.temperature( - state, attr[ATTR_UNIT_OF_MEASUREMENT]) - state = str(state) + try: + unit_of_measure = attr.get(ATTR_UNIT_OF_MEASUREMENT) + if unit_of_measure in (TEMP_CELSIUS, TEMP_FAHRENHEIT): + units = self.hass.config.units + state = str(units.temperature(float(state), unit_of_measure)) + attr[ATTR_UNIT_OF_MEASUREMENT] = units.temperature_unit + except ValueError: + # Could not convert state to float + pass return self.hass.states.set( self.entity_id, state, attr, self.force_update) diff --git a/homeassistant/helpers/event_decorators.py b/homeassistant/helpers/event_decorators.py index d4292f20a5f..aed90599a75 100644 --- a/homeassistant/helpers/event_decorators.py +++ b/homeassistant/helpers/event_decorators.py @@ -1,9 +1,13 @@ """Event Decorators for custom components.""" import functools +# pylint: disable=unused-import +from typing import Optional # NOQA + +from homeassistant.core import HomeAssistant # NOQA from homeassistant.helpers import event -HASS = None +HASS = None # type: Optional[HomeAssistant] def track_state_change(entity_ids, from_state=None, to_state=None): diff --git a/homeassistant/helpers/location.py b/homeassistant/helpers/location.py index a3cdc348a24..c84d02dcb83 100644 --- a/homeassistant/helpers/location.py +++ b/homeassistant/helpers/location.py @@ -1,18 +1,21 @@ """Location helpers for Home Assistant.""" +from typing import Sequence + from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import State from homeassistant.util import location as loc_util -def has_location(state): +def has_location(state: State) -> bool: """Test if state contains a valid location.""" return (isinstance(state, State) and isinstance(state.attributes.get(ATTR_LATITUDE), float) and isinstance(state.attributes.get(ATTR_LONGITUDE), float)) -def closest(latitude, longitude, states): +def closest(latitude: float, longitude: float, + states: Sequence[State]) -> State: """Return closest state to point.""" with_location = [state for state in states if has_location(state)] diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index bc1382ef982..008fdb9374d 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -2,14 +2,17 @@ import logging import threading from itertools import islice +from typing import Optional, Sequence import voluptuous as vol -import homeassistant.util.dt as date_util +from homeassistant.core import HomeAssistant from homeassistant.const import EVENT_TIME_CHANGED, CONF_CONDITION +from homeassistant.helpers import ( + service, condition, template, config_validation as cv) from homeassistant.helpers.event import track_point_in_utc_time -from homeassistant.helpers import service, condition, template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType +import homeassistant.util.dt as date_util _LOGGER = logging.getLogger(__name__) @@ -22,7 +25,8 @@ CONF_EVENT_DATA = "event_data" CONF_DELAY = "delay" -def call_from_config(hass, config, variables=None): +def call_from_config(hass: HomeAssistant, config: ConfigType, + variables: Optional[Sequence]=None) -> None: """Call a script based on a config entry.""" Script(hass, config).run(variables) @@ -31,7 +35,8 @@ class Script(): """Representation of a script.""" # pylint: disable=too-many-instance-attributes - def __init__(self, hass, sequence, name=None, change_listener=None): + def __init__(self, hass: HomeAssistant, sequence, name: str=None, + change_listener=None) -> None: """Initialize the script.""" self.hass = hass self.sequence = cv.SCRIPT_SCHEMA(sequence) @@ -45,11 +50,11 @@ class Script(): self._delay_listener = None @property - def is_running(self): + def is_running(self) -> bool: """Return true if script is on.""" return self._cur != -1 - def run(self, variables=None): + def run(self, variables: Optional[Sequence]=None) -> None: """Run script.""" with self._lock: if self._cur == -1: @@ -101,7 +106,7 @@ class Script(): if self._change_listener: self._change_listener() - def stop(self): + def stop(self) -> None: """Stop running script.""" with self._lock: if self._cur == -1: diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 95dce9516de..b594889fd77 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1,16 +1,19 @@ """Service calling related helpers.""" import functools import logging +# pylint: disable=unused-import +from typing import Optional # NOQA import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant # NOQA from homeassistant.exceptions import TemplateError from homeassistant.helpers import template from homeassistant.loader import get_component import homeassistant.helpers.config_validation as cv -HASS = None +HASS = None # type: Optional[HomeAssistant] CONF_SERVICE = 'service' CONF_SERVICE_TEMPLATE = 'service_template' diff --git a/homeassistant/helpers/temperature.py b/homeassistant/helpers/temperature.py deleted file mode 100644 index 6155c08d8f0..00000000000 --- a/homeassistant/helpers/temperature.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Methods to help handle temperature in Home Assistant.""" -import homeassistant.util.temperature as temp_util -from homeassistant.const import TEMP_CELSIUS - - -def convert(temperature, unit, to_unit): - """Convert temperature to correct unit.""" - if unit == to_unit or unit is None or to_unit is None: - return temperature - elif unit == TEMP_CELSIUS: - return temp_util.celsius_to_fahrenheit(temperature) - - return temp_util.fahrenheit_to_celsius(temperature) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 8ccfd1e0bf5..fab081cc5c5 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -219,7 +219,8 @@ class LocationMethods(object): if len(locations) == 1: return self._hass.config.distance(*locations[0]) - return loc_util.distance(*locations[0] + locations[1]) + return self._hass.config.units.length( + loc_util.distance(*locations[0] + locations[1]), 'm') def _resolve_state(self, entity_id_or_state): """Return state or entity_id if given.""" diff --git a/homeassistant/helpers/typing.py b/homeassistant/helpers/typing.py new file mode 100644 index 00000000000..6eb53f14493 --- /dev/null +++ b/homeassistant/helpers/typing.py @@ -0,0 +1,24 @@ +"""Typing Helpers for Home-Assistant.""" +from typing import Dict, Any + +# NOTE: NewType added to typing in 3.5.2 in June, 2016; Since 3.5.2 includes +# security fixes everyone on 3.5 should upgrade "soon" +try: + from typing import NewType +except ImportError: + NewType = None + +# pylint: disable=invalid-name +if NewType: + ConfigType = NewType('ConfigType', Dict[str, Any]) + + # Custom type for recorder Queries + QueryType = NewType('QueryType', Any) + +# Duplicates for 3.5.1 +# pylint: disable=invalid-name +else: + ConfigType = Dict[str, Any] # type: ignore + + # Custom type for recorder Queries + QueryType = Any # type: ignore diff --git a/homeassistant/remote.py b/homeassistant/remote.py index 409d276caf5..8e62cdd044a 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -15,6 +15,8 @@ import time import threading import urllib.parse +from typing import Optional + import requests import homeassistant.bootstrap as bootstrap @@ -42,7 +44,7 @@ class APIStatus(enum.Enum): CANNOT_CONNECT = "cannot_connect" UNKNOWN = "unknown" - def __str__(self): + def __str__(self) -> str: """Return the state.""" return self.value @@ -51,7 +53,8 @@ class API(object): """Object to pass around Home Assistant API location and credentials.""" # pylint: disable=too-few-public-methods - def __init__(self, host, api_password=None, port=None, use_ssl=False): + def __init__(self, host: str, api_password: Optional[str]=None, + port: Optional[int]=None, use_ssl: bool=False) -> None: """Initalize the API.""" self.host = host self.port = port or SERVER_PORT @@ -68,7 +71,7 @@ class API(object): if api_password is not None: self._headers[HTTP_HEADER_HA_AUTH] = api_password - def validate_api(self, force_validate=False): + def validate_api(self, force_validate: bool=False) -> bool: """Test if we can communicate with the API.""" if self.status is None or force_validate: self.status = validate_api(self) @@ -100,7 +103,7 @@ class API(object): _LOGGER.exception(error) raise HomeAssistantError(error) - def __repr__(self): + def __repr__(self) -> str: """Return the representation of the API.""" return "API({}, {}, {})".format( self.host, self.api_password, self.port) diff --git a/homeassistant/scripts/__init__.py b/homeassistant/scripts/__init__.py index 87771045b66..d737726f78e 100644 --- a/homeassistant/scripts/__init__.py +++ b/homeassistant/scripts/__init__.py @@ -1,12 +1,27 @@ """Home Assistant command line scripts.""" +import argparse import importlib import os +import sys +import logging +from typing import List + +from homeassistant.config import get_default_config_dir +from homeassistant.util.package import install_package +from homeassistant.bootstrap import mount_local_lib_path -def run(args: str) -> int: +def run(args: List) -> int: """Run a script.""" - scripts = [fil[:-3] for fil in os.listdir(os.path.dirname(__file__)) - if fil.endswith('.py') and fil != '__init__.py'] + scripts = [] + path = os.path.dirname(__file__) + for fil in os.listdir(path): + if fil == '__pycache__': + continue + elif os.path.isdir(os.path.join(path, fil)): + scripts.append(fil) + elif fil != '__init__.py' and fil.endswith('.py'): + scripts.append(fil[:-3]) if not args: print('Please specify a script to run.') @@ -19,4 +34,23 @@ def run(args: str) -> int: return 1 script = importlib.import_module('homeassistant.scripts.' + args[0]) + + config_dir = extract_config_dir() + deps_dir = mount_local_lib_path(config_dir) + + logging.basicConfig(stream=sys.stdout, level=logging.INFO) + for req in getattr(script, 'REQUIREMENTS', []): + if not install_package(req, target=deps_dir): + print('Aborting scipt, could not install dependency', req) + return 1 + return script.run(args[1:]) # type: ignore + + +def extract_config_dir(args=None) -> str: + """Extract the config dir from the arguments or get the default.""" + parser = argparse.ArgumentParser() + parser.add_argument('-c', '--config', default=None) + args = parser.parse_known_args(args)[0] + return (os.path.join(os.getcwd(), args.config) if args.config + else get_default_config_dir()) diff --git a/homeassistant/scripts/db_migrator.py b/homeassistant/scripts/db_migrator.py index 3ce26014c05..7e48bb3a1d7 100644 --- a/homeassistant/scripts/db_migrator.py +++ b/homeassistant/scripts/db_migrator.py @@ -4,19 +4,17 @@ import argparse import os.path import sqlite3 import sys -try: - from sqlalchemy import create_engine - from sqlalchemy.orm import sessionmaker -except ImportError: - print('Fatal Error: SQLAlchemy is missing. Install it with ' - '"pip3 install SQLAlchemy" before running this script') - sys.exit(1) -from homeassistant.components.recorder import models + +from datetime import datetime +from typing import Optional, List + import homeassistant.config as config_util import homeassistant.util.dt as dt_util +# pylint: disable=unused-import +from homeassistant.components.recorder import REQUIREMENTS # NOQA -def ts_to_dt(timestamp): +def ts_to_dt(timestamp: Optional[float]) -> Optional[datetime]: """Turn a datetime into an integer for in the DB.""" if timestamp is None: return None @@ -26,8 +24,8 @@ def ts_to_dt(timestamp): # Based on code at # http://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console # pylint: disable=too-many-arguments -def print_progress(iteration, total, prefix='', suffix='', decimals=2, - bar_length=68): +def print_progress(iteration: int, total: int, prefix: str='', suffix: str='', + decimals: int=2, bar_length: int=68) -> None: """Print progress bar. Call in a loop to create terminal progress bar @@ -49,9 +47,13 @@ def print_progress(iteration, total, prefix='', suffix='', decimals=2, print("\n") -def run(args): +def run(script_args: List) -> int: """The actual script body.""" # pylint: disable=too-many-locals,invalid-name,too-many-statements + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from homeassistant.components.recorder import models + parser = argparse.ArgumentParser( description="Migrate legacy DB to SQLAlchemy format.") parser.add_argument( @@ -75,7 +77,7 @@ def run(args): args = parser.parse_args() - config_dir = os.path.join(os.getcwd(), args.config) + config_dir = os.path.join(os.getcwd(), args.config) # type: str # Test if configuration directory exists if not os.path.isdir(config_dir): @@ -116,7 +118,7 @@ def run(args): c = conn.cursor() n = 0 - for row in c.execute("SELECT * FROM recorder_runs"): + for row in c.execute("SELECT * FROM recorder_runs"): # type: ignore n += 1 session.add(models.RecorderRuns( start=ts_to_dt(row[1]), @@ -141,7 +143,7 @@ def run(args): c = conn.cursor() n = 0 - for row in c.execute("SELECT * FROM events"): + for row in c.execute("SELECT * FROM events"): # type: ignore n += 1 o = models.Events( event_type=row[1], @@ -169,7 +171,7 @@ def run(args): c = conn.cursor() n = 0 - for row in c.execute("SELECT * FROM states"): + for row in c.execute("SELECT * FROM states"): # type: ignore n += 1 session.add(models.States( entity_id=row[1], diff --git a/homeassistant/scripts/keyring.py b/homeassistant/scripts/keyring.py new file mode 100644 index 00000000000..dba67a35197 --- /dev/null +++ b/homeassistant/scripts/keyring.py @@ -0,0 +1,57 @@ +"""Script to get, set and delete secrets stored in the keyring.""" +import os +import argparse +import getpass + +from homeassistant.util.yaml import _SECRET_NAMESPACE + +REQUIREMENTS = ['keyring>=9.3,<10.0'] + + +def run(args): + """Handle keyring script.""" + parser = argparse.ArgumentParser( + description=("Modify Home-Assistant secrets in the default keyring. " + "Use the secrets in configuration files with: " + "!secret ")) + parser.add_argument( + '--script', choices=['keyring']) + parser.add_argument( + 'action', choices=['get', 'set', 'del', 'info'], + help="Get, set or delete a secret") + parser.add_argument( + 'name', help="Name of the secret", nargs='?', default=None) + + import keyring + from keyring.util import platform_ as platform + + args = parser.parse_args(args) + + if args.action == 'info': + keyr = keyring.get_keyring() + print('Keyring version {}\n'.format(keyring.__version__)) + print('Active keyring : {}'.format(keyr.__module__)) + config_name = os.path.join(platform.config_root(), 'keyringrc.cfg') + print('Config location : {}'.format(config_name)) + print('Data location : {}\n'.format(platform.data_root())) + elif args.name is None: + parser.print_help() + return 1 + + if args.action == 'set': + the_secret = getpass.getpass('Please enter the secret for {}: ' + .format(args.name)) + keyring.set_password(_SECRET_NAMESPACE, args.name, the_secret) + print('Secret {} set successfully'.format(args.name)) + elif args.action == 'get': + the_secret = keyring.get_password(_SECRET_NAMESPACE, args.name) + if the_secret is None: + print('Secret {} not found'.format(args.name)) + else: + print('Secret {}={}'.format(args.name, the_secret)) + elif args.action == 'del': + try: + keyring.delete_password(_SECRET_NAMESPACE, args.name) + print('Deleted secret {}'.format(args.name)) + except keyring.errors.PasswordDeleteError: + print('Secret {} not found'.format(args.name)) diff --git a/homeassistant/scripts/macos.py b/homeassistant/scripts/macos/__init__.py similarity index 94% rename from homeassistant/scripts/macos.py rename to homeassistant/scripts/macos/__init__.py index e16d1f6c272..a37275e715f 100644 --- a/homeassistant/scripts/macos.py +++ b/homeassistant/scripts/macos/__init__.py @@ -11,8 +11,7 @@ def install_osx(): with os.popen('whoami') as inp: user = inp.read().strip() - cwd = os.path.dirname(__file__) - template_path = os.path.join(cwd, 'startup', 'launchd.plist') + template_path = os.path.join(os.path.dirname(__file__), 'launchd.plist') with open(template_path, 'r', encoding='utf-8') as inp: plist = inp.read() diff --git a/homeassistant/startup/launchd.plist b/homeassistant/scripts/macos/launchd.plist similarity index 100% rename from homeassistant/startup/launchd.plist rename to homeassistant/scripts/macos/launchd.plist diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index e0f856c7444..032588f6cba 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -12,21 +12,24 @@ import string from functools import wraps from types import MappingProxyType -from typing import Any, Sequence +from typing import Any, Optional, TypeVar, Callable, Sequence from .dt import as_local, utcnow +T = TypeVar('T') +U = TypeVar('U') + RE_SANITIZE_FILENAME = re.compile(r'(~|\.\.|/|\\)') RE_SANITIZE_PATH = re.compile(r'(~|\.(\.)+)') RE_SLUGIFY = re.compile(r'[^a-z0-9_]+') -def sanitize_filename(filename): +def sanitize_filename(filename: str) -> str: r"""Sanitize a filename by removing .. / and \\.""" return RE_SANITIZE_FILENAME.sub("", filename) -def sanitize_path(path): +def sanitize_path(path: str) -> str: """Sanitize a path by removing ~ and ..""" return RE_SANITIZE_PATH.sub("", path) @@ -50,7 +53,8 @@ def repr_helper(inp: Any) -> str: return str(inp) -def convert(value, to_type, default=None): +def convert(value: T, to_type: Callable[[T], U], + default: Optional[U]=None) -> Optional[U]: """Convert value to to_type, returns default if fails.""" try: return default if value is None else to_type(value) diff --git a/homeassistant/util/distance.py b/homeassistant/util/distance.py new file mode 100644 index 00000000000..ef4e6c02b1a --- /dev/null +++ b/homeassistant/util/distance.py @@ -0,0 +1,88 @@ +"""Distance util functions.""" + +import logging +from numbers import Number + +from homeassistant.const import ( + LENGTH_KILOMETERS, + LENGTH_MILES, + LENGTH_FEET, + LENGTH_METERS, + UNIT_NOT_RECOGNIZED_TEMPLATE, + LENGTH, +) + +_LOGGER = logging.getLogger(__name__) + +VALID_UNITS = [ + LENGTH_KILOMETERS, + LENGTH_MILES, + LENGTH_FEET, + LENGTH_METERS, +] + + +def convert(value: float, unit_1: str, unit_2: str) -> float: + """Convert one unit of measurement to another.""" + if unit_1 not in VALID_UNITS: + raise ValueError( + UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_1, LENGTH)) + if unit_2 not in VALID_UNITS: + raise ValueError( + UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_2, LENGTH)) + + if not isinstance(value, Number): + raise TypeError('{} is not of numeric type'.format(value)) + + if unit_1 == unit_2 or unit_1 not in VALID_UNITS: + return value + + meters = value + + if unit_1 == LENGTH_MILES: + meters = __miles_to_meters(value) + elif unit_1 == LENGTH_FEET: + meters = __feet_to_meters(value) + elif unit_1 == LENGTH_KILOMETERS: + meters = __kilometers_to_meters(value) + + result = meters + + if unit_2 == LENGTH_MILES: + result = __meters_to_miles(meters) + elif unit_2 == LENGTH_FEET: + result = __meters_to_feet(meters) + elif unit_2 == LENGTH_KILOMETERS: + result = __meters_to_kilometers(meters) + + return result + + +def __miles_to_meters(miles: float) -> float: + """Convert miles to meters.""" + return miles * 1609.344 + + +def __feet_to_meters(feet: float) -> float: + """Convert feet to meters.""" + return feet * 0.3048 + + +def __kilometers_to_meters(kilometers: float) -> float: + """Convert kilometers to meters.""" + return kilometers * 1000 + + +def __meters_to_miles(meters: float) -> float: + """Convert meters to miles.""" + return meters * 0.000621371 + + +def __meters_to_feet(meters: float) -> float: + """Convert meters to feet.""" + return meters * 3.28084 + + +def __meters_to_kilometers(meters: float) -> float: + """Convert meters to kilometers.""" + return meters * 0.001 diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index a5724ee90e1..282ddf9bb8c 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -8,7 +8,7 @@ from typing import Any, Union, Optional, Tuple # NOQA import pytz DATE_STR_FORMAT = "%Y-%m-%d" -UTC = DEFAULT_TIME_ZONE = pytz.utc # type: pytz.UTC +UTC = DEFAULT_TIME_ZONE = pytz.utc # type: dt.tzinfo # Copyright (c) Django Software Foundation and individual contributors. @@ -93,11 +93,10 @@ def start_of_local_day(dt_or_d: Union[dt.date, dt.datetime]=None) -> dt.datetime: """Return local datetime object of start of day from date or datetime.""" if dt_or_d is None: - dt_or_d = now().date() + date = now().date() # type: dt.date elif isinstance(dt_or_d, dt.datetime): - dt_or_d = dt_or_d.date() - - return DEFAULT_TIME_ZONE.localize(dt.datetime.combine(dt_or_d, dt.time())) + date = dt_or_d.date() + return DEFAULT_TIME_ZONE.localize(dt.datetime.combine(date, dt.time())) # Copyright (c) Django Software Foundation and individual contributors. @@ -118,6 +117,8 @@ def parse_datetime(dt_str: str) -> dt.datetime: if kws['microsecond']: kws['microsecond'] = kws['microsecond'].ljust(6, '0') tzinfo_str = kws.pop('tzinfo') + + tzinfo = None # type: Optional[dt.tzinfo] if tzinfo_str == 'Z': tzinfo = UTC elif tzinfo_str is not None: diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index 1cc8ffe0b9f..1fb1c22c2cd 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -31,7 +31,7 @@ LocationInfo = collections.namedtuple( "LocationInfo", ['ip', 'country_code', 'country_name', 'region_code', 'region_name', 'city', 'zip_code', 'time_zone', 'latitude', 'longitude', - 'use_fahrenheit']) + 'use_metric']) def detect_location_info(): @@ -44,11 +44,8 @@ def detect_location_info(): if data is None: return None - # From Wikipedia: Fahrenheit is used in the Bahamas, Belize, - # the Cayman Islands, Palau, and the United States and associated - # territories of American Samoa and the U.S. Virgin Islands - data['use_fahrenheit'] = data['country_code'] in ( - 'BS', 'BZ', 'KY', 'PW', 'US', 'AS', 'VI') + data['use_metric'] = data['country_code'] not in ( + 'US', 'MM', 'LR') return LocationInfo(**data) diff --git a/homeassistant/util/temperature.py b/homeassistant/util/temperature.py index 59112a709ca..d6e245de04f 100644 --- a/homeassistant/util/temperature.py +++ b/homeassistant/util/temperature.py @@ -1,14 +1,10 @@ """Temperature util functions.""" - -import logging - - -def fahrenheit_to_celcius(fahrenheit: float) -> float: - """**DEPRECATED** Convert a Fahrenheit temperature to Celsius.""" - logging.getLogger(__name__).warning( - 'fahrenheit_to_celcius is now fahrenheit_to_celsius ' - 'correcting a spelling mistake') - return fahrenheit_to_celsius(fahrenheit) +from homeassistant.const import ( + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + UNIT_NOT_RECOGNIZED_TEMPLATE, + TEMPERATURE +) def fahrenheit_to_celsius(fahrenheit: float) -> float: @@ -16,14 +12,23 @@ def fahrenheit_to_celsius(fahrenheit: float) -> float: return (fahrenheit - 32.0) / 1.8 -def celcius_to_fahrenheit(celcius: float) -> float: - """**DEPRECATED** Convert a Celsius temperature to Fahrenheit.""" - logging.getLogger(__name__).warning( - 'celcius_to_fahrenheit is now celsius_to_fahrenheit correcting ' - 'a spelling mistake') - return celsius_to_fahrenheit(celcius) - - def celsius_to_fahrenheit(celsius: float) -> float: """Convert a Celsius temperature to Fahrenheit.""" return celsius * 1.8 + 32.0 + + +def convert(temperature: float, from_unit: str, to_unit: str) -> float: + """Convert a temperature from one unit to another.""" + if from_unit not in (TEMP_CELSIUS, TEMP_FAHRENHEIT): + raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(from_unit, + TEMPERATURE)) + if to_unit not in (TEMP_CELSIUS, TEMP_FAHRENHEIT): + raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, + TEMPERATURE)) + + if from_unit == to_unit: + return temperature + elif from_unit == TEMP_CELSIUS: + return celsius_to_fahrenheit(temperature) + else: + return round(fahrenheit_to_celsius(temperature), 1) diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py new file mode 100644 index 00000000000..a83a2b9a2ba --- /dev/null +++ b/homeassistant/util/unit_system.py @@ -0,0 +1,125 @@ +"""Unit system helper class and methods.""" + +import logging +from numbers import Number +from homeassistant.const import ( + TEMP_CELSIUS, TEMP_FAHRENHEIT, LENGTH_CENTIMETERS, LENGTH_METERS, + LENGTH_KILOMETERS, LENGTH_INCHES, LENGTH_FEET, LENGTH_YARD, LENGTH_MILES, + VOLUME_LITERS, VOLUME_MILLILITERS, VOLUME_GALLONS, VOLUME_FLUID_OUNCE, + MASS_GRAMS, MASS_KILOGRAMS, MASS_OUNCES, MASS_POUNDS, + CONF_UNIT_SYSTEM_METRIC, + CONF_UNIT_SYSTEM_IMPERIAL, LENGTH, MASS, VOLUME, TEMPERATURE, + UNIT_NOT_RECOGNIZED_TEMPLATE) +from homeassistant.util import temperature as temperature_util +from homeassistant.util import distance as distance_util + +_LOGGER = logging.getLogger(__name__) + +LENGTH_UNITS = [ + LENGTH_MILES, + LENGTH_YARD, + LENGTH_FEET, + LENGTH_INCHES, + LENGTH_KILOMETERS, + LENGTH_METERS, + LENGTH_CENTIMETERS, +] + +MASS_UNITS = [ + MASS_POUNDS, + MASS_OUNCES, + MASS_KILOGRAMS, + MASS_GRAMS, +] + +VOLUME_UNITS = [ + VOLUME_GALLONS, + VOLUME_FLUID_OUNCE, + VOLUME_LITERS, + VOLUME_MILLILITERS, +] + +TEMPERATURE_UNITS = [ + TEMP_FAHRENHEIT, + TEMP_CELSIUS, +] + + +def is_valid_unit(unit: str, unit_type: str) -> bool: + """Check if the unit is valid for it's type.""" + if unit_type == LENGTH: + units = LENGTH_UNITS + elif unit_type == TEMPERATURE: + units = TEMPERATURE_UNITS + elif unit_type == MASS: + units = MASS_UNITS + elif unit_type == VOLUME: + units = VOLUME_UNITS + else: + return False + + return unit in units + + +class UnitSystem(object): + """A container for units of measure.""" + + # pylint: disable=too-many-arguments + def __init__(self: object, name: str, temperature: str, length: str, + volume: str, mass: str) -> None: + """Initialize the unit system object.""" + errors = \ + ', '.join(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit, unit_type) + for unit, unit_type in [ + (temperature, TEMPERATURE), + (length, LENGTH), + (volume, VOLUME), + (mass, MASS), ] + if not is_valid_unit(unit, unit_type)) # type: str + + if errors: + raise ValueError(errors) + + self.name = name + self.temperature_unit = temperature + self.length_unit = length + self.mass_unit = mass + self.volume_unit = volume + + @property + def is_metric(self: object) -> bool: + """Determine if this is the metric unit system.""" + return self.name == CONF_UNIT_SYSTEM_METRIC + + def temperature(self: object, temperature: float, from_unit: str) -> float: + """Convert the given temperature to this unit system.""" + if not isinstance(temperature, Number): + raise TypeError( + '{} is not a numeric value.'.format(str(temperature))) + + return temperature_util.convert(temperature, + from_unit, self.temperature_unit) + + def length(self: object, length: float, from_unit: str) -> float: + """Convert the given length to this unit system.""" + if not isinstance(length, Number): + raise TypeError('{} is not a numeric value.'.format(str(length))) + + return distance_util.convert(length, from_unit, + self.length_unit) # type: float + + def as_dict(self) -> dict: + """Convert the unit system to a dictionary.""" + return { + LENGTH: self.length_unit, + MASS: self.mass_unit, + TEMPERATURE: self.temperature_unit, + VOLUME: self.volume_unit + } + + +METRIC_SYSTEM = UnitSystem(CONF_UNIT_SYSTEM_METRIC, TEMP_CELSIUS, + LENGTH_KILOMETERS, VOLUME_LITERS, MASS_GRAMS) + +IMPERIAL_SYSTEM = UnitSystem(CONF_UNIT_SYSTEM_IMPERIAL, TEMP_FAHRENHEIT, + LENGTH_MILES, VOLUME_GALLONS, MASS_POUNDS) diff --git a/requirements_all.txt b/requirements_all.txt index 582bc60c9d8..e2be50c8f4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -4,9 +4,8 @@ pyyaml>=3.11,<4 pytz>=2016.6.1 pip>=7.0.0 jinja2>=2.8 -voluptuous==0.8.9 +voluptuous==0.9.2 typing>=3,<4 -sqlalchemy==1.0.14 # homeassistant.components.isy994 PyISY==1.0.6 @@ -47,7 +46,7 @@ blockchain==1.3.3 boto3==1.3.1 # homeassistant.components.http -cherrypy==6.1.1 +cherrypy==7.1.0 # homeassistant.components.media_player.directv directpy==0.1 @@ -81,7 +80,7 @@ fixerio==0.1.1 freesms==0.1.0 # homeassistant.components.conversation -fuzzywuzzy==0.11.0 +fuzzywuzzy==0.11.1 # homeassistant.components.notify.gntp gntp==1.0.3 @@ -89,6 +88,12 @@ gntp==1.0.3 # homeassistant.components.sensor.google_travel_time googlemaps==2.4.4 +# homeassistant.components.sensor.gpsd +gps3==0.33.2 + +# homeassistant.components.camera.ffmpeg +ha-ffmpeg==0.4 + # homeassistant.components.mqtt.server hbmqtt==0.7.1 @@ -102,7 +107,7 @@ hikvision==0.4 # http://github.com/mala-zaba/Adafruit_Python_DHT/archive/4101340de8d2457dd194bca1e8d11cbfc237e919.zip#Adafruit_DHT==1.1.0 # homeassistant.components.light.flux_led -https://github.com/Danielhiversen/flux_led/archive/0.3.zip#flux_led==0.3 +https://github.com/Danielhiversen/flux_led/archive/0.6.zip#flux_led==0.6 # homeassistant.components.switch.dlink https://github.com/LinuxChristian/pyW215/archive/v0.1.1.zip#pyW215==0.1.1 @@ -148,6 +153,9 @@ https://github.com/kellerza/pyqwikswitch/archive/v0.4.zip#pyqwikswitch==0.4 # homeassistant.components.media_player.russound_rnet https://github.com/laf/russound/archive/0.1.6.zip#russound==0.1.6 +# homeassistant.components.sensor.fastdotcom +https://github.com/nkgilley/fast.com/archive/master.zip#fastdotcom==0.0.1 + # homeassistant.components.ecobee https://github.com/nkgilley/python-ecobee-api/archive/4856a704670c53afe1882178a89c209b5f98533d.zip#python-ecobee==0.0.6 @@ -205,7 +213,7 @@ messagebird==1.2.0 mficlient==0.3.0 # homeassistant.components.discovery -netdisco==0.7.0 +netdisco==0.7.1 # homeassistant.components.sensor.neurio_energy neurio==0.2.10 @@ -227,12 +235,18 @@ pexpect==4.0.1 # homeassistant.components.light.hue phue==0.8 +# homeassistant.components.pilight +pilight==0.0.2 + # homeassistant.components.media_player.plex # homeassistant.components.sensor.plex -plexapi==1.1.0 +plexapi==2.0.2 + +# homeassistant.components.sensor.serial_pm +pmsensor==0.2 # homeassistant.components.thermostat.proliphix -proliphix==0.1.0 +proliphix==0.3.1 # homeassistant.components.sensor.systemmonitor psutil==4.3.0 @@ -257,7 +271,7 @@ pushetta==1.0.15 py-cpuinfo==0.2.3 # homeassistant.components.rfxtrx -pyRFXtrx==0.9.0 +pyRFXtrx==0.10.1 # homeassistant.components.notify.xmpp pyasn1-modules==0.0.8 @@ -285,7 +299,7 @@ pyenvisalink==1.0 pyfttt==0.3 # homeassistant.components.homematic -pyhomematic==0.1.10 +pyhomematic==0.1.11 # homeassistant.components.device_tracker.icloud pyicloud==0.9.1 @@ -332,7 +346,7 @@ python-mystrom==0.3.6 python-nest==2.9.2 # homeassistant.components.device_tracker.nmap_tracker -python-nmap==0.6.0 +python-nmap==0.6.1 # homeassistant.components.notify.pushover python-pushover==0.2 @@ -381,7 +395,7 @@ schiene==0.17 scsgate==0.1.0 # homeassistant.components.notify.sendgrid -sendgrid==3.0.7 +sendgrid==3.1.10 # homeassistant.components.notify.slack slacker==0.9.24 @@ -453,7 +467,7 @@ xbee-helper==0.0.7 xmltodict==0.10.2 # homeassistant.components.sensor.yweather -yahooweather==0.4 +yahooweather==0.6 # homeassistant.components.zeroconf zeroconf==0.17.6 diff --git a/script/lint_docker b/script/lint_docker new file mode 100755 index 00000000000..61f4e4be96a --- /dev/null +++ b/script/lint_docker @@ -0,0 +1,5 @@ +#!/bin/bash +set -e + +docker build -t home-assistant-test -f virtualization/Docker/Dockerfile.test . +docker run --rm -it home-assistant-test tox -e lint diff --git a/script/test_docker b/script/test_docker new file mode 100755 index 00000000000..ab2296cf5fc --- /dev/null +++ b/script/test_docker @@ -0,0 +1,5 @@ +#!/bin/bash +set -e + +docker build -t home-assistant-test -f virtualization/Docker/Dockerfile.test . +docker run --rm -it home-assistant-test tox -e py34 diff --git a/setup.py b/setup.py index 7a9c1333230..caa5b177b5c 100755 --- a/setup.py +++ b/setup.py @@ -16,9 +16,8 @@ REQUIRES = [ 'pytz>=2016.6.1', 'pip>=7.0.0', 'jinja2>=2.8', - 'voluptuous==0.8.9', + 'voluptuous==0.9.2', 'typing>=3,<4', - 'sqlalchemy==1.0.14', ] setup( diff --git a/tests/common.py b/tests/common.py index 4fd9da96ae3..b0e3ef17653 100644 --- a/tests/common.py +++ b/tests/common.py @@ -6,11 +6,12 @@ from unittest import mock from homeassistant import core as ha, loader from homeassistant.bootstrap import _setup_component from homeassistant.helpers.entity import ToggleEntity +from homeassistant.util.unit_system import METRIC_SYSTEM import homeassistant.util.dt as date_util from homeassistant.const import ( STATE_ON, STATE_OFF, DEVICE_DEFAULT_NAME, EVENT_TIME_CHANGED, EVENT_STATE_CHANGED, EVENT_PLATFORM_DISCOVERED, ATTR_SERVICE, - ATTR_DISCOVERED, SERVER_PORT, TEMP_CELSIUS) + ATTR_DISCOVERED, SERVER_PORT) from homeassistant.components import sun, mqtt _TEST_INSTANCE_PORT = SERVER_PORT @@ -37,7 +38,7 @@ def get_test_home_assistant(num_threads=None): hass.config.longitude = -117.22743 hass.config.elevation = 0 hass.config.time_zone = date_util.get_time_zone('US/Pacific') - hass.config.temperature_unit = TEMP_CELSIUS + hass.config.units = METRIC_SYSTEM if 'custom_components.test' not in loader.AVAILABLE_COMPONENTS: loader.prepare(hass) diff --git a/tests/components/hvac/test_demo.py b/tests/components/hvac/test_demo.py index bdb155e43b4..536a1ac36f7 100644 --- a/tests/components/hvac/test_demo.py +++ b/tests/components/hvac/test_demo.py @@ -1,8 +1,8 @@ """The tests for the demo hvac.""" import unittest -from homeassistant.const import ( - TEMP_CELSIUS, +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, ) from homeassistant.components import hvac @@ -18,7 +18,7 @@ class TestDemoHvac(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.temperature_unit = TEMP_CELSIUS + self.hass.config.units = METRIC_SYSTEM self.assertTrue(hvac.setup(self.hass, {'hvac': { 'platform': 'demo', }})) diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py new file mode 100755 index 00000000000..d149d2ba04c --- /dev/null +++ b/tests/components/light/test_mqtt_json.py @@ -0,0 +1,344 @@ +"""The tests for the MQTT JSON light platform. + +Configuration for RGB Version with brightness: + +light: + platform: mqtt_json + name: mqtt_json_light_1 + state_topic: "home/rgb1" + command_topic: "home/rgb1/set" + brightness: true + rgb: true + +Config without RGB: + +light: + platform: mqtt_json + name: mqtt_json_light_1 + state_topic: "home/rgb1" + command_topic: "home/rgb1/set" + brightness: true + +Config without RGB and brightness: + +light: + platform: mqtt_json + name: mqtt_json_light_1 + state_topic: "home/rgb1" + command_topic: "home/rgb1/set" +""" +import json +import unittest + +from homeassistant.bootstrap import _setup_component +from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ASSUMED_STATE +import homeassistant.components.light as light +from tests.common import ( + get_test_home_assistant, mock_mqtt_component, fire_mqtt_message) + + +class TestLightMQTTJSON(unittest.TestCase): + """Test the MQTT JSON light.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.mock_publish = mock_mqtt_component(self.hass) + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + def test_fail_setup_if_no_command_topic(self): + """Test if setup fails with no command topic.""" + self.hass.config.components = ['mqtt'] + assert not _setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + } + }) + self.assertIsNone(self.hass.states.get('light.test')) + + def test_no_color_or_brightness_if_no_config(self): + """Test if there is no color and brightness if they aren't defined.""" + self.hass.config.components = ['mqtt'] + assert _setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + + fire_mqtt_message(self.hass, 'test_light_rgb', '{"state":"ON"}') + self.hass.pool.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + + def test_controlling_state_via_topic(self): + """Test the controlling of the state via topic.""" + self.hass.config.components = ['mqtt'] + assert _setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'brightness': True, + 'rgb': True, + 'qos': '0' + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get(ATTR_ASSUMED_STATE)) + + # Turn on the light, full white + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + + '"color":{"r":255,"g":255,"b":255},' + + '"brightness":255}' + ) + self.hass.pool.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + self.assertEqual(255, state.attributes.get('brightness')) + + # Turn the light off + fire_mqtt_message(self.hass, 'test_light_rgb', '{"state":"OFF"}') + self.hass.pool.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + + '"brightness":100}' + ) + self.hass.pool.block_till_done() + + light_state = self.hass.states.get('light.test') + self.hass.pool.block_till_done() + self.assertEqual(100, + light_state.attributes['brightness']) + + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + + '"color":{"r":125,"g":125,"b":125}}' + ) + self.hass.pool.block_till_done() + + light_state = self.hass.states.get('light.test') + self.assertEqual([125, 125, 125], + light_state.attributes.get('rgb_color')) + + def test_sending_mqtt_commands_and_optimistic(self): + """Test the sending of command in optimistic mode.""" + self.hass.config.components = ['mqtt'] + assert _setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'brightness': True, + 'rgb': True, + 'qos': 2 + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) + + light.turn_on(self.hass, 'light.test') + self.hass.pool.block_till_done() + + self.assertEqual(('test_light_rgb/set', '{"state": "ON"}', 2, False), + self.mock_publish.mock_calls[-1][1]) + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + + light.turn_off(self.hass, 'light.test') + self.hass.pool.block_till_done() + + self.assertEqual(('test_light_rgb/set', '{"state": "OFF"}', 2, False), + self.mock_publish.mock_calls[-1][1]) + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + light.turn_on(self.hass, 'light.test', rgb_color=[75, 75, 75], + brightness=50) + self.hass.pool.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.mock_calls[-1][1][0]) + self.assertEqual(2, self.mock_publish.mock_calls[-1][1][2]) + self.assertEqual(False, self.mock_publish.mock_calls[-1][1][3]) + # Get the sent message + message_json = json.loads(self.mock_publish.mock_calls[-1][1][1]) + self.assertEqual(50, message_json["brightness"]) + self.assertEqual(75, message_json["color"]["r"]) + self.assertEqual(75, message_json["color"]["g"]) + self.assertEqual(75, message_json["color"]["b"]) + self.assertEqual("ON", message_json["state"]) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual((75, 75, 75), state.attributes['rgb_color']) + self.assertEqual(50, state.attributes['brightness']) + + def test_flash_short_and_long(self): + """Test for flash length being sent when included.""" + self.hass.config.components = ['mqtt'] + assert _setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'flash_time_short': 5, + 'flash_time_long': 15, + 'qos': 0 + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + light.turn_on(self.hass, 'light.test', flash="short") + self.hass.pool.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.mock_calls[-1][1][0]) + self.assertEqual(0, self.mock_publish.mock_calls[-1][1][2]) + self.assertEqual(False, self.mock_publish.mock_calls[-1][1][3]) + # Get the sent message + message_json = json.loads(self.mock_publish.mock_calls[-1][1][1]) + self.assertEqual(5, message_json["flash"]) + self.assertEqual("ON", message_json["state"]) + + light.turn_on(self.hass, 'light.test', flash="long") + self.hass.pool.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.mock_calls[-1][1][0]) + self.assertEqual(0, self.mock_publish.mock_calls[-1][1][2]) + self.assertEqual(False, self.mock_publish.mock_calls[-1][1][3]) + # Get the sent message + message_json = json.loads(self.mock_publish.mock_calls[-1][1][1]) + self.assertEqual(15, message_json["flash"]) + self.assertEqual("ON", message_json["state"]) + + def test_transition(self): + """Test for transition time being sent when included.""" + self.hass.config.components = ['mqtt'] + assert _setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'qos': 0 + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + light.turn_on(self.hass, 'light.test', transition=10) + self.hass.pool.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.mock_calls[-1][1][0]) + self.assertEqual(0, self.mock_publish.mock_calls[-1][1][2]) + self.assertEqual(False, self.mock_publish.mock_calls[-1][1][3]) + # Get the sent message + message_json = json.loads(self.mock_publish.mock_calls[-1][1][1]) + self.assertEqual(10, message_json["transition"]) + self.assertEqual("ON", message_json["state"]) + + # Transition back off + light.turn_off(self.hass, 'light.test', transition=10) + self.hass.pool.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.mock_calls[-1][1][0]) + self.assertEqual(0, self.mock_publish.mock_calls[-1][1][2]) + self.assertEqual(False, self.mock_publish.mock_calls[-1][1][3]) + # Get the sent message + message_json = json.loads(self.mock_publish.mock_calls[-1][1][1]) + self.assertEqual(10, message_json["transition"]) + self.assertEqual("OFF", message_json["state"]) + + def test_invalid_color_and_brightness_values(self): + """Test that invalid color/brightness values are ignored.""" + self.hass.config.components = ['mqtt'] + assert _setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'brightness': True, + 'rgb': True, + 'qos': '0' + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get(ATTR_ASSUMED_STATE)) + + # Turn on the light + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + + '"color":{"r":255,"g":255,"b":255},' + + '"brightness": 255}' + ) + self.hass.pool.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + self.assertEqual(255, state.attributes.get('brightness')) + + # Bad color values + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + + '"color":{"r":"bad","g":"val","b":"test"}}' + ) + self.hass.pool.block_till_done() + + # Color should not have changed + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + + # Bad brightness values + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + + '"brightness": "badValue"}' + ) + self.hass.pool.block_till_done() + + # Brightness should not have changed + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual(255, state.attributes.get('brightness')) diff --git a/tests/components/media_player/test_demo.py b/tests/components/media_player/test_demo.py index aa7350ff930..f2b9ce4b032 100644 --- a/tests/components/media_player/test_demo.py +++ b/tests/components/media_player/test_demo.py @@ -1,23 +1,50 @@ """The tests for the Demo Media player platform.""" import unittest from unittest.mock import patch +from homeassistant import bootstrap import homeassistant.components.media_player as mp +import homeassistant.components.http as http -from tests.common import get_test_home_assistant +import requests +import requests_mock +import time + +from tests.common import get_test_home_assistant, get_test_instance_port + +SERVER_PORT = get_test_instance_port() +HTTP_BASE_URL = 'http://127.0.0.1:{}'.format(SERVER_PORT) + +hass = None entity_id = 'media_player.walkman' +def setUpModule(): # pylint: disable=invalid-name + """Initalize a Home Assistant server.""" + global hass + + hass = get_test_home_assistant() + bootstrap.setup_component(hass, http.DOMAIN, { + http.DOMAIN: { + http.CONF_SERVER_PORT: SERVER_PORT + }, + }) + + hass.start() + time.sleep(0.05) + + +def tearDownModule(): # pylint: disable=invalid-name + """Stop the Home Assistant server.""" + hass.stop() + + class TestDemoMediaPlayer(unittest.TestCase): """Test the media_player module.""" def setUp(self): # pylint: disable=invalid-name """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() + self.hass = hass def test_source_select(self): """Test the input source service.""" @@ -175,6 +202,19 @@ class TestDemoMediaPlayer(unittest.TestCase): assert 0 == (mp.SUPPORT_PREVIOUS_TRACK & state.attributes.get('supported_media_commands')) + @requests_mock.Mocker(real_http=True) + def test_media_image_proxy(self, m): + """Test the media server image proxy server .""" + fake_picture_data = 'test.test' + m.get('https://graph.facebook.com/v2.5/107771475912710/' + 'picture?type=large', text=fake_picture_data) + assert mp.setup(self.hass, {'media_player': {'platform': 'demo'}}) + assert self.hass.states.is_state(entity_id, 'playing') + state = self.hass.states.get(entity_id) + req = requests.get(HTTP_BASE_URL + + state.attributes.get('entity_picture')) + assert req.text == fake_picture_data + @patch('homeassistant.components.media_player.demo.DemoYoutubePlayer.' 'media_seek') def test_play_media(self, mock_seek): diff --git a/tests/components/notify/test_demo.py b/tests/components/notify/test_demo.py index 0d4f2115ca7..3f7ffb576ed 100644 --- a/tests/components/notify/test_demo.py +++ b/tests/components/notify/test_demo.py @@ -1,8 +1,11 @@ """The tests for the notify demo platform.""" +import tempfile import unittest import homeassistant.components.notify as notify from homeassistant.components.notify import demo +from homeassistant.helpers import script +from homeassistant.util import yaml from tests.common import get_test_home_assistant @@ -45,3 +48,48 @@ class TestNotifyDemo(unittest.TestCase): last_event = self.events[-1] self.assertEqual(last_event.data[notify.ATTR_TITLE], 'temperature') self.assertEqual(last_event.data[notify.ATTR_MESSAGE], '10') + + def test_method_forwards_correct_data(self): + """Test that all data from the service gets forwarded to service.""" + notify.send_message(self.hass, 'my message', 'my title', + {'hello': 'world'}) + self.hass.pool.block_till_done() + self.assertTrue(len(self.events) == 1) + data = self.events[0].data + assert { + 'message': 'my message', + 'target': None, + 'title': 'my title', + 'data': {'hello': 'world'} + } == data + + def test_calling_notify_from_script_loaded_from_yaml(self): + """Test if we can call a notify from a script.""" + yaml_conf = """ +service: notify.notify +data: + data: + push: + sound: US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav +data_template: + message: > + Test 123 {{ 2 + 2 }} +""" + + with tempfile.NamedTemporaryFile() as fp: + fp.write(yaml_conf.encode('utf-8')) + fp.flush() + conf = yaml.load_yaml(fp.name) + + script.call_from_config(self.hass, conf) + self.hass.pool.block_till_done() + self.assertTrue(len(self.events) == 1) + assert { + 'message': 'Test 123 4', + 'target': None, + 'title': 'Home Assistant', + 'data': { + 'push': { + 'sound': + 'US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav'}} + } == self.events[0].data diff --git a/tests/components/notify/test_smtp.py b/tests/components/notify/test_smtp.py new file mode 100644 index 00000000000..7fa61fbdc24 --- /dev/null +++ b/tests/components/notify/test_smtp.py @@ -0,0 +1,52 @@ +"""The tests for the notify smtp platform.""" +import unittest + +from homeassistant.components.notify import smtp + +from tests.common import get_test_home_assistant + + +class MockSMTP(smtp.MailNotificationService): + """Test SMTP object that doesn't need a working server.""" + + def connection_is_valid(self): + """Pretend connection is always valid for testing.""" + return True + + def _send_email(self, msg): + """Just return string for testing.""" + return msg.as_string() + + +class TestNotifySmtp(unittest.TestCase): + """Test the smtp notify.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.mailer = MockSMTP('localhost', 25, 'test@test.com', 1, 'testuser', + 'testpass', 'testrecip@test.com', 0) + + def tearDown(self): # pylint: disable=invalid-name + """"Stop down everything that was started.""" + self.hass.stop() + + def test_text_email(self): + """Test build of default text email behavior.""" + msg = self.mailer.send_message('Test msg') + expected = ('Content-Type: text/plain; charset="us-ascii"\n' + 'MIME-Version: 1.0\n' + 'Content-Transfer-Encoding: 7bit\n' + 'Subject: \n' + 'To: testrecip@test.com\n' + 'From: test@test.com\n' + 'X-Mailer: HomeAssistant\n' + '\n' + 'Test msg') + self.assertEqual(msg, expected) + + def test_mixed_email(self): + """Test build of mixed text email behavior.""" + msg = self.mailer.send_message('Test msg', + data={'images': ['test.jpg']}) + self.assertTrue('Content-Type: multipart/related' in msg) diff --git a/tests/components/switch/test_flux.py b/tests/components/switch/test_flux.py index 78d1f5190d6..3e12f2e2d37 100644 --- a/tests/components/switch/test_flux.py +++ b/tests/components/switch/test_flux.py @@ -480,3 +480,87 @@ class TestSwitchFlux(unittest.TestCase): call = turn_on_calls[-3] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 171) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.452, 0.386]) + + def test_flux_with_mired(self): + """Test the flux switch´s mode mired.""" + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + light.setup(self.hass, {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('color_temp')) + + test_time = dt_util.now().replace(hour=8, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, + minute=0, + second=0) + timedelta(days=1) + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.components.sun.next_rising', + return_value=sunrise_time): + with patch('homeassistant.components.sun.next_setting', + return_value=sunset_time): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'mode': 'mired' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.pool.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.pool.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_COLOR_TEMP], 269) + + def test_flux_with_kelvin(self): + """Test the flux switch´s mode kelvin.""" + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + light.setup(self.hass, {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('color_temp')) + + test_time = dt_util.now().replace(hour=8, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, + minute=0, + second=0) + timedelta(days=1) + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.components.sun.next_rising', + return_value=sunrise_time): + with patch('homeassistant.components.sun.next_setting', + return_value=sunset_time): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'mode': 'kelvin' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.pool.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.pool.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_COLOR_TEMP], 3708) diff --git a/tests/components/switch/test_rfxtrx.py b/tests/components/switch/test_rfxtrx.py index f0146719e75..72c13bd0ba4 100644 --- a/tests/components/switch/test_rfxtrx.py +++ b/tests/components/switch/test_rfxtrx.py @@ -40,7 +40,7 @@ class TestSwitchRfxtrx(unittest.TestCase): 'switch': {'platform': 'rfxtrx', 'automatic_add': True, 'devices': - {'710000141010170': { + {710000141010170: { 'name': 'Test', rfxtrx_core.ATTR_FIREEVENT: True} }}})) diff --git a/tests/components/test_http.py b/tests/components/test_http.py index 6ab79f3e0cc..7a6d7af673f 100644 --- a/tests/components/test_http.py +++ b/tests/components/test_http.py @@ -12,12 +12,15 @@ from tests.common import get_test_instance_port, get_test_home_assistant API_PASSWORD = "test1234" SERVER_PORT = get_test_instance_port() -HTTP_BASE_URL = "http://127.0.0.1:{}".format(SERVER_PORT) +HTTP_BASE = "127.0.0.1:{}".format(SERVER_PORT) +HTTP_BASE_URL = "http://{}".format(HTTP_BASE) HA_HEADERS = { const.HTTP_HEADER_HA_AUTH: API_PASSWORD, const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON, } +CORS_ORIGINS = [HTTP_BASE_URL, HTTP_BASE] + hass = None @@ -38,7 +41,8 @@ def setUpModule(): # pylint: disable=invalid-name bootstrap.setup_component( hass, http.DOMAIN, {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD, - http.CONF_SERVER_PORT: SERVER_PORT}}) + http.CONF_SERVER_PORT: SERVER_PORT, + http.CONF_CORS_ORIGINS: CORS_ORIGINS}}) bootstrap.setup_component(hass, 'api') @@ -61,7 +65,7 @@ class TestHttp: assert req.status_code == 401 def test_access_denied_with_wrong_password_in_header(self): - """Test ascces with wrong password.""" + """Test access with wrong password.""" req = requests.get( _url(const.URL_API), headers={const.HTTP_HEADER_HA_AUTH: 'wrongpassword'}) @@ -86,7 +90,7 @@ class TestHttp: assert API_PASSWORD not in logs def test_access_denied_with_wrong_password_in_url(self): - """Test ascces with wrong password.""" + """Test access with wrong password.""" req = requests.get(_url(const.URL_API), params={'api_password': 'wrongpassword'}) @@ -107,3 +111,67 @@ class TestHttp: # assert const.URL_API in logs assert API_PASSWORD not in logs + + def test_cors_allowed_with_password_in_url(self): + """Test cross origin resource sharing with password in url.""" + req = requests.get(_url(const.URL_API), + params={'api_password': API_PASSWORD}, + headers={const.HTTP_HEADER_ORIGIN: HTTP_BASE_URL}) + + allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN + allow_headers = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS + all_allow_headers = ", ".join(const.ALLOWED_CORS_HEADERS) + + assert req.status_code == 200 + assert req.headers.get(allow_origin) == HTTP_BASE_URL + assert req.headers.get(allow_headers) == all_allow_headers + + def test_cors_allowed_with_password_in_header(self): + """Test cross origin resource sharing with password in header.""" + headers = { + const.HTTP_HEADER_HA_AUTH: API_PASSWORD, + const.HTTP_HEADER_ORIGIN: HTTP_BASE_URL + } + req = requests.get(_url(const.URL_API), + headers=headers) + + allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN + allow_headers = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS + all_allow_headers = ", ".join(const.ALLOWED_CORS_HEADERS) + + assert req.status_code == 200 + assert req.headers.get(allow_origin) == HTTP_BASE_URL + assert req.headers.get(allow_headers) == all_allow_headers + + def test_cors_denied_without_origin_header(self): + """Test cross origin resource sharing with password in header.""" + headers = { + const.HTTP_HEADER_HA_AUTH: API_PASSWORD + } + req = requests.get(_url(const.URL_API), + headers=headers) + + allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN + allow_headers = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS + + assert req.status_code == 200 + assert allow_origin not in req.headers + assert allow_headers not in req.headers + + def test_cors_preflight_allowed(self): + """Test cross origin resource sharing preflight (OPTIONS) request.""" + headers = { + const.HTTP_HEADER_ORIGIN: HTTP_BASE_URL, + 'Access-Control-Request-Method': 'GET', + 'Access-Control-Request-Headers': 'x-ha-access' + } + req = requests.options(_url(const.URL_API), + headers=headers) + + allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN + allow_headers = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS + all_allow_headers = ", ".join(const.ALLOWED_CORS_HEADERS) + + assert req.status_code == 200 + assert req.headers.get(allow_origin) == HTTP_BASE_URL + assert req.headers.get(allow_headers) == all_allow_headers diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 2f7fd705d20..7d15557c1bf 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -85,6 +85,18 @@ class TestComponentLogbook(unittest.TestCase): self.assert_entry( entries[1], pointC, 'bla', domain='sensor', entity_id=entity_id) + def test_filter_continuous_sensor_values(self): + """Test remove continuous sensor events from logbook.""" + entity_id = 'sensor.bla' + pointA = dt_util.utcnow() + attributes = {'unit_of_measurement': 'foo'} + eventA = self.create_state_changed_event( + pointA, entity_id, 10, attributes) + + entries = list(logbook.humanify((eventA,))) + + self.assertEqual(0, len(entries)) + def test_entry_to_dict(self): """Test conversion of entry to dict.""" entry = logbook.Entry( @@ -148,11 +160,12 @@ class TestComponentLogbook(unittest.TestCase): if entity_id: self.assertEqual(entity_id, entry.entity_id) - def create_state_changed_event(self, event_time_fired, entity_id, state): + def create_state_changed_event(self, event_time_fired, entity_id, state, + attributes=None): """Create state changed event.""" # Logbook only cares about state change events that # contain an old state but will not actually act on it. - state = ha.State(entity_id, state).as_dict() + state = ha.State(entity_id, state, attributes).as_dict() return ha.Event(EVENT_STATE_CHANGED, { 'entity_id': entity_id, diff --git a/tests/components/test_panel_custom.py b/tests/components/test_panel_custom.py new file mode 100644 index 00000000000..6a41706db98 --- /dev/null +++ b/tests/components/test_panel_custom.py @@ -0,0 +1,77 @@ +"""The tests for the panel_custom component.""" +import os +import shutil +from tempfile import NamedTemporaryFile +import unittest +from unittest.mock import patch + +from homeassistant import bootstrap +from homeassistant.components import panel_custom + +from tests.common import get_test_home_assistant + + +@patch('homeassistant.components.frontend.setup', return_value=True) +class TestPanelCustom(unittest.TestCase): + """Test the panel_custom component.""" + + def setup_method(self, method): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + shutil.rmtree(self.hass.config.path(panel_custom.PANEL_DIR), + ignore_errors=True) + + @patch('homeassistant.components.panel_custom.register_panel') + def test_webcomponent_in_panels_dir(self, mock_register, _mock_setup): + """Test if a web component is found in config panels dir.""" + config = { + 'panel_custom': { + 'name': 'todomvc', + } + } + + assert not bootstrap.setup_component(self.hass, 'panel_custom', config) + assert not mock_register.called + + path = self.hass.config.path(panel_custom.PANEL_DIR) + os.mkdir(path) + + with open(os.path.join(path, 'todomvc.html'), 'a'): + assert bootstrap.setup_component(self.hass, 'panel_custom', config) + assert mock_register.called + + @patch('homeassistant.components.panel_custom.register_panel') + def test_webcomponent_custom_path(self, mock_register, _mock_setup): + """Test if a web component is found in config panels dir.""" + with NamedTemporaryFile() as fp: + config = { + 'panel_custom': { + 'name': 'todomvc', + 'webcomponent_path': fp.name, + 'sidebar_title': 'Sidebar Title', + 'sidebar_icon': 'mdi:iconicon', + 'url_path': 'nice_url', + 'config': 5, + } + } + + with patch('os.path.isfile', return_value=False): + assert not bootstrap.setup_component(self.hass, 'panel_custom', + config) + assert not mock_register.called + + assert bootstrap.setup_component(self.hass, 'panel_custom', config) + assert mock_register.called + args = mock_register.mock_calls[0][1] + kwargs = mock_register.mock_calls[0][2] + assert args == (self.hass, 'todomvc', fp.name) + assert kwargs == { + 'config': 5, + 'url_path': 'nice_url', + 'sidebar_icon': 'mdi:iconicon', + 'sidebar_title': 'Sidebar Title' + } diff --git a/tests/components/test_panel_iframe.py b/tests/components/test_panel_iframe.py index 7b024c9ed56..ac479dea645 100644 --- a/tests/components/test_panel_iframe.py +++ b/tests/components/test_panel_iframe.py @@ -63,7 +63,7 @@ class TestPanelIframe(unittest.TestCase): 'icon': 'mdi:network-wireless', 'title': 'Router', 'url': '/frontend/panels/iframe-md5md5.html', - 'url_name': 'router' + 'url_path': 'router' } assert frontend.PANELS['weather'] == { @@ -72,5 +72,5 @@ class TestPanelIframe(unittest.TestCase): 'icon': 'mdi:weather', 'title': 'Weather', 'url': '/frontend/panels/iframe-md5md5.html', - 'url_name': 'weather', + 'url_path': 'weather', } diff --git a/tests/components/test_proximity.py b/tests/components/test_proximity.py index 0bfa403ad44..479b9459f03 100644 --- a/tests/components/test_proximity.py +++ b/tests/components/test_proximity.py @@ -18,11 +18,73 @@ class TestProximity: 'longitude': 1.1, 'radius': 10 }) + self.hass.states.set( + 'zone.work', 'zoning', + { + 'name': 'work', + 'latitude': 2.3, + 'longitude': 1.3, + 'radius': 10 + }) def teardown_method(self, method): """Stop everything that was started.""" self.hass.stop() + def test_proximities(self): + """Test a list of proximities.""" + assert proximity.setup(self.hass, { + 'proximity': [{ + 'zone': 'home', + 'ignored_zones': { + 'work' + }, + 'devices': { + 'device_tracker.test1', + 'device_tracker.test2' + }, + 'tolerance': '1' + }, { + 'zone': 'work', + 'devices': { + 'device_tracker.test1' + }, + 'tolerance': '1' + }] + }) + + proximities = ['home', 'work'] + + for prox in proximities: + state = self.hass.states.get('proximity.' + prox) + assert state.state == 'not set' + assert state.attributes.get('nearest') == 'not set' + assert state.attributes.get('dir_of_travel') == 'not set' + + self.hass.states.set('proximity.' + prox, '0') + self.hass.pool.block_till_done() + state = self.hass.states.get('proximity.' + prox) + assert state.state == '0' + + def test_proximities_missing_devices(self): + """Test a list of proximities with one missing devices.""" + assert not proximity.setup(self.hass, { + 'proximity': [{ + 'zone': 'home', + 'ignored_zones': { + 'work' + }, + 'devices': { + 'device_tracker.test1', + 'device_tracker.test2' + }, + 'tolerance': '1' + }, { + 'zone': 'work', + 'tolerance': '1' + }] + }) + def test_proximity(self): """Test the proximity.""" assert proximity.setup(self.hass, { diff --git a/tests/components/thermostat/test_demo.py b/tests/components/thermostat/test_demo.py index c4c6a52a20f..673626136ab 100644 --- a/tests/components/thermostat/test_demo.py +++ b/tests/components/thermostat/test_demo.py @@ -1,8 +1,8 @@ """The tests for the demo thermostat.""" import unittest -from homeassistant.const import ( - TEMP_CELSIUS, +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, ) from homeassistant.components import thermostat @@ -18,7 +18,7 @@ class TestDemoThermostat(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.temperature_unit = TEMP_CELSIUS + self.hass.config.units = METRIC_SYSTEM self.assertTrue(thermostat.setup(self.hass, {'thermostat': { 'platform': 'demo', }})) @@ -43,10 +43,10 @@ class TestDemoThermostat(unittest.TestCase): def test_set_target_temp_bad_attr(self): """Test setting the target temperature without required attribute.""" - self.assertEqual('21', self.hass.states.get(ENTITY_NEST).state) + self.assertEqual('21.0', self.hass.states.get(ENTITY_NEST).state) thermostat.set_temperature(self.hass, None, ENTITY_NEST) self.hass.pool.block_till_done() - self.assertEqual('21', self.hass.states.get(ENTITY_NEST).state) + self.assertEqual('21.0', self.hass.states.get(ENTITY_NEST).state) def test_set_target_temp(self): """Test the setting of the target temperature.""" diff --git a/tests/components/thermostat/test_heat_control.py b/tests/components/thermostat/test_heat_control.py index ca3572d1710..a01c1595393 100644 --- a/tests/components/thermostat/test_heat_control.py +++ b/tests/components/thermostat/test_heat_control.py @@ -1,5 +1,8 @@ """The tests for the heat control thermostat.""" +import datetime import unittest +from unittest import mock + from homeassistant.bootstrap import _setup_component from homeassistant.const import ( @@ -10,6 +13,7 @@ from homeassistant.const import ( STATE_OFF, TEMP_CELSIUS, ) +from homeassistant.util.unit_system import METRIC_SYSTEM from homeassistant.components import thermostat from tests.common import get_test_home_assistant @@ -75,7 +79,7 @@ class TestThermostatHeatControl(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.temperature_unit = TEMP_CELSIUS + self.hass.config.units = METRIC_SYSTEM thermostat.setup(self.hass, {'thermostat': { 'platform': 'heat_control', 'name': 'test', @@ -123,19 +127,29 @@ class TestThermostatHeatControl(unittest.TestCase): def test_sensor_bad_unit(self): """Test sensor that have bad unit.""" + state = self.hass.states.get(ENTITY) + temp = state.attributes.get('current_temperature') + unit = state.attributes.get('unit_of_measurement') + self._setup_sensor(22.0, unit='bad_unit') self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY) - self.assertEqual(None, state.attributes.get('unit_of_measurement')) - self.assertEqual(None, state.attributes.get('current_temperature')) + self.assertEqual(unit, state.attributes.get('unit_of_measurement')) + self.assertEqual(temp, state.attributes.get('current_temperature')) def test_sensor_bad_value(self): """Test sensor that have None as state.""" + state = self.hass.states.get(ENTITY) + temp = state.attributes.get('current_temperature') + unit = state.attributes.get('unit_of_measurement') + self._setup_sensor(None) self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY) - self.assertEqual(None, state.attributes.get('unit_of_measurement')) - self.assertEqual(None, state.attributes.get('current_temperature')) + self.assertEqual(unit, state.attributes.get('unit_of_measurement')) + self.assertEqual(temp, state.attributes.get('current_temperature')) def test_set_target_temp_heater_on(self): """Test if target temperature turn heater on.""" @@ -206,3 +220,274 @@ class TestThermostatHeatControl(unittest.TestCase): self.hass.services.register('switch', SERVICE_TURN_ON, log_call) self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) + + +class TestThermostatHeatControlACMode(unittest.TestCase): + """Test the Heat Control thermostat.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.temperature_unit = TEMP_CELSIUS + thermostat.setup(self.hass, {'thermostat': { + 'platform': 'heat_control', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'ac_mode': True + }}) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_set_target_temp_ac_off(self): + """Test if target temperature turn ac off.""" + self._setup_switch(True) + self._setup_sensor(25) + self.hass.pool.block_till_done() + thermostat.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_set_target_temp_ac_on(self): + """Test if target temperature turn ac on.""" + self._setup_switch(False) + self._setup_sensor(30) + self.hass.pool.block_till_done() + thermostat.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_set_temp_change_ac_off(self): + """Test if temperature change turn ac off.""" + self._setup_switch(True) + thermostat.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_temp_change_ac_on(self): + """Test if temperature change turn ac on.""" + self._setup_switch(False) + thermostat.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def _setup_sensor(self, temp, unit=TEMP_CELSIUS): + """Setup the test sensor.""" + self.hass.states.set(ENT_SENSOR, temp, { + ATTR_UNIT_OF_MEASUREMENT: unit + }) + + def _setup_switch(self, is_on): + """Setup the test switch.""" + self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF) + self.calls = [] + + def log_call(call): + """Log service calls.""" + self.calls.append(call) + + self.hass.services.register('switch', SERVICE_TURN_ON, log_call) + self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) + + +class TestThermostatHeatControlACModeMinCycle(unittest.TestCase): + """Test the Heat Control thermostat.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.temperature_unit = TEMP_CELSIUS + thermostat.setup(self.hass, {'thermostat': { + 'platform': 'heat_control', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'ac_mode': True, + 'min_cycle_duration': datetime.timedelta(minutes=10) + }}) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_temp_change_ac_trigger_on_not_long_enough(self): + """Test if temperature change turn ac on.""" + self._setup_switch(False) + thermostat.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_temp_change_ac_trigger_on_long_enough(self): + """Test if temperature change turn ac on.""" + fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, + tzinfo=datetime.timezone.utc) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=fake_changed): + self._setup_switch(False) + thermostat.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_temp_change_ac_trigger_off_not_long_enough(self): + """Test if temperature change turn ac on.""" + self._setup_switch(True) + thermostat.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_temp_change_ac_trigger_off_long_enough(self): + """Test if temperature change turn ac on.""" + fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, + tzinfo=datetime.timezone.utc) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=fake_changed): + self._setup_switch(True) + thermostat.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def _setup_sensor(self, temp, unit=TEMP_CELSIUS): + """Setup the test sensor.""" + self.hass.states.set(ENT_SENSOR, temp, { + ATTR_UNIT_OF_MEASUREMENT: unit + }) + + def _setup_switch(self, is_on): + """Setup the test switch.""" + self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF) + self.calls = [] + + def log_call(call): + """Log service calls.""" + self.calls.append(call) + + self.hass.services.register('switch', SERVICE_TURN_ON, log_call) + self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) + + +class TestThermostatHeatControlMinCycle(unittest.TestCase): + """Test the Heat Control thermostat.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.temperature_unit = TEMP_CELSIUS + thermostat.setup(self.hass, {'thermostat': { + 'platform': 'heat_control', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'min_cycle_duration': datetime.timedelta(minutes=10) + }}) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_temp_change_heater_trigger_off_not_long_enough(self): + """Test if temp change doesn't turn heater off because of time.""" + self._setup_switch(True) + thermostat.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_temp_change_heater_trigger_on_not_long_enough(self): + """Test if temp change doesn't turn heater on because of time.""" + self._setup_switch(False) + thermostat.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_temp_change_heater_trigger_on_long_enough(self): + """Test if temperature change turn heater on after min cycle.""" + fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, + tzinfo=datetime.timezone.utc) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=fake_changed): + self._setup_switch(False) + thermostat.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_temp_change_heater_trigger_off_long_enough(self): + """Test if temperature change turn heater off after min cycle.""" + fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, + tzinfo=datetime.timezone.utc) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=fake_changed): + self._setup_switch(True) + thermostat.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def _setup_sensor(self, temp, unit=TEMP_CELSIUS): + """Setup the test sensor.""" + self.hass.states.set(ENT_SENSOR, temp, { + ATTR_UNIT_OF_MEASUREMENT: unit + }) + + def _setup_switch(self, is_on): + """Setup the test switch.""" + self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF) + self.calls = [] + + def log_call(call): + """Log service calls.""" + self.calls.append(call) + + self.hass.services.register('switch', SERVICE_TURN_ON, log_call) + self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) diff --git a/tests/components/thermostat/test_honeywell.py b/tests/components/thermostat/test_honeywell.py index 7a5b4c93a98..e4f75e508e5 100644 --- a/tests/components/thermostat/test_honeywell.py +++ b/tests/components/thermostat/test_honeywell.py @@ -277,6 +277,16 @@ class TestHoneywellRound(unittest.TestCase): self.round1.set_temperature(25) self.device.set_temperature.assert_called_once_with('House', 25) + def test_set_hvac_mode(self: unittest.TestCase) -> None: + """Test setting the system operation.""" + self.round1.set_hvac_mode('cool') + self.assertEqual('cool', self.round1.operation) + self.assertEqual('cool', self.device.system_mode) + + self.round1.set_hvac_mode('heat') + self.assertEqual('heat', self.round1.operation) + self.assertEqual('heat', self.device.system_mode) + class TestHoneywellUS(unittest.TestCase): """A test class for Honeywell US thermostats.""" @@ -327,6 +337,16 @@ class TestHoneywellUS(unittest.TestCase): self.assertEqual(74, self.device.setpoint_cool) self.assertEqual(74, self.honeywell.target_temperature) + def test_set_hvac_mode(self: unittest.TestCase) -> None: + """Test setting the HVAC mode.""" + self.honeywell.set_hvac_mode('cool') + self.assertEqual('cool', self.honeywell.operation) + self.assertEqual('cool', self.device.system_mode) + + self.honeywell.set_hvac_mode('heat') + self.assertEqual('heat', self.honeywell.operation) + self.assertEqual('heat', self.device.system_mode) + def test_set_temp_fail(self): """Test if setting the temperature fails.""" self.device.setpoint_heat = mock.MagicMock( diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index b73dc6d6f94..7f94ab53b23 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -253,7 +253,7 @@ def test_template(): schema = vol.Schema(cv.template) for value in ( - None, '{{ partial_print }', '{% if True %}Hello' + None, '{{ partial_print }', '{% if True %}Hello', {'dict': 'isbad'} ): with pytest.raises(vol.MultipleInvalid): schema(value) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index a465c2f2c74..593e8b433c0 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -37,11 +37,6 @@ class TestHelpersEntity(unittest.TestCase): state = self.hass.states.get(self.entity.entity_id) self.assertTrue(state.attributes.get(ATTR_HIDDEN)) - def test_split_entity_id(self): - """Test split_entity_id.""" - self.assertEqual(['domain', 'object_id'], - entity.split_entity_id('domain.object_id')) - def test_generate_entity_id_requires_hass_or_ids(self): """Ensure we require at least hass or current ids.""" fmt = 'test.{}' diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 1529c879aab..266138d1fd5 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -6,6 +6,13 @@ from unittest.mock import patch from homeassistant.components import group from homeassistant.exceptions import TemplateError from homeassistant.helpers import template +from homeassistant.util.unit_system import UnitSystem +from homeassistant.const import ( + LENGTH_METERS, + TEMP_CELSIUS, + MASS_GRAMS, + VOLUME_LITERS, +) import homeassistant.util.dt as dt_util from tests.common import get_test_home_assistant @@ -17,6 +24,9 @@ class TestUtilTemplate(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """Setup the tests.""" self.hass = get_test_home_assistant() + self.hass.config.units = UnitSystem('custom', TEMP_CELSIUS, + LENGTH_METERS, VOLUME_LITERS, + MASS_GRAMS) def tearDown(self): # pylint: disable=invalid-name """Stop down stuff we started.""" diff --git a/tests/scripts/__init__.py b/tests/scripts/__init__.py new file mode 100644 index 00000000000..52065c53e74 --- /dev/null +++ b/tests/scripts/__init__.py @@ -0,0 +1 @@ +"""Tests for the scripts.""" diff --git a/tests/scripts/test_init.py b/tests/scripts/test_init.py new file mode 100644 index 00000000000..7a8a74c4b65 --- /dev/null +++ b/tests/scripts/test_init.py @@ -0,0 +1,19 @@ +"""Test script init.""" +import unittest +from unittest.mock import patch + +import homeassistant.scripts as scripts + + +class TestScripts(unittest.TestCase): + """Tests homeassistant.scripts module.""" + + @patch('homeassistant.scripts.get_default_config_dir', + return_value='/default') + def test_config_per_platform(self, mock_def): + """Test config per platform method.""" + self.assertEquals(scripts.get_default_config_dir(), '/default') + self.assertEqual(scripts.extract_config_dir(), '/default') + self.assertEqual(scripts.extract_config_dir(['']), '/default') + self.assertEqual(scripts.extract_config_dir(['-c', '/arg']), '/arg') + self.assertEqual(scripts.extract_config_dir(['--config', '/a']), '/a') diff --git a/tests/test_config.py b/tests/test_config.py index 0a6321c2240..4a4f1ef9b6f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -11,9 +11,9 @@ from voluptuous import MultipleInvalid from homeassistant.core import DOMAIN, HomeAssistantError, Config import homeassistant.config as config_util from homeassistant.const import ( - CONF_LATITUDE, CONF_LONGITUDE, CONF_TEMPERATURE_UNIT, CONF_NAME, + CONF_LATITUDE, CONF_LONGITUDE, CONF_UNIT_SYSTEM, CONF_NAME, CONF_TIME_ZONE, CONF_ELEVATION, CONF_CUSTOMIZE, __version__, - TEMP_FAHRENHEIT) + CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT) from homeassistant.util import location as location_util, dt as dt_util from homeassistant.helpers.entity import Entity @@ -145,7 +145,7 @@ class TestConfig(unittest.TestCase): CONF_LATITUDE: 32.8594, CONF_LONGITUDE: -117.2073, CONF_ELEVATION: 101, - CONF_TEMPERATURE_UNIT: 'F', + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, CONF_NAME: 'Home', CONF_TIME_ZONE: 'America/Los_Angeles' } @@ -167,7 +167,7 @@ class TestConfig(unittest.TestCase): def test_core_config_schema(self): for value in ( - {'temperature_unit': 'K'}, + {CONF_UNIT_SYSTEM: 'K'}, {'time_zone': 'non-exist'}, {'latitude': '91'}, {'longitude': -181}, @@ -182,7 +182,7 @@ class TestConfig(unittest.TestCase): 'name': 'Test name', 'latitude': '-23.45', 'longitude': '123.45', - 'temperature_unit': 'c', + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, 'customize': { 'sensor.temperature': { 'hidden': True, @@ -264,7 +264,7 @@ class TestConfig(unittest.TestCase): 'longitude': 50, 'elevation': 25, 'name': 'Huis', - 'temperature_unit': 'F', + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, 'time_zone': 'America/New_York', }) @@ -272,7 +272,28 @@ class TestConfig(unittest.TestCase): assert config.longitude == 50 assert config.elevation == 25 assert config.location_name == 'Huis' - assert config.temperature_unit == TEMP_FAHRENHEIT + assert config.units.name == CONF_UNIT_SYSTEM_IMPERIAL + assert config.time_zone.zone == 'America/New_York' + + def test_loading_configuration_temperature_unit(self): + """Test backward compatibility when loading core config.""" + config = Config() + hass = mock.Mock(config=config) + + config_util.process_ha_core_config(hass, { + 'latitude': 60, + 'longitude': 50, + 'elevation': 25, + 'name': 'Huis', + CONF_TEMPERATURE_UNIT: 'C', + 'time_zone': 'America/New_York', + }) + + assert config.latitude == 60 + assert config.longitude == 50 + assert config.elevation == 25 + assert config.location_name == 'Huis' + assert config.units.name == CONF_UNIT_SYSTEM_METRIC assert config.time_zone.zone == 'America/New_York' @mock.patch('homeassistant.util.location.detect_location_info', @@ -292,7 +313,8 @@ class TestConfig(unittest.TestCase): assert config.longitude == -117.2073 assert config.elevation == 101 assert config.location_name == 'San Diego' - assert config.temperature_unit == TEMP_FAHRENHEIT + assert config.units.name == CONF_UNIT_SYSTEM_METRIC + assert config.units.is_metric assert config.time_zone.zone == 'America/Los_Angeles' @mock.patch('homeassistant.util.location.detect_location_info', @@ -311,5 +333,5 @@ class TestConfig(unittest.TestCase): assert config.longitude == blankConfig.longitude assert config.elevation == blankConfig.elevation assert config.location_name == blankConfig.location_name - assert config.temperature_unit == blankConfig.temperature_unit + assert config.units == blankConfig.units assert config.time_zone == blankConfig.time_zone diff --git a/tests/test_core.py b/tests/test_core.py index e9513a2adb8..aa3cdd2aecc 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -15,16 +15,25 @@ import homeassistant.core as ha from homeassistant.exceptions import ( HomeAssistantError, InvalidEntityFormatError) import homeassistant.util.dt as dt_util +from homeassistant.util.unit_system import (METRIC_SYSTEM) from homeassistant.const import ( __version__, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - EVENT_STATE_CHANGED, ATTR_FRIENDLY_NAME, TEMP_CELSIUS, - TEMP_FAHRENHEIT) + EVENT_STATE_CHANGED, ATTR_FRIENDLY_NAME, CONF_UNIT_SYSTEM) from tests.common import get_test_home_assistant PST = pytz.timezone('America/Los_Angeles') +class TestMethods(unittest.TestCase): + """Test the Home Assistant helper methods.""" + + def test_split_entity_id(self): + """Test split_entity_id.""" + self.assertEqual(['domain', 'object_id'], + ha.split_entity_id('domain.object_id')) + + class TestHomeAssistant(unittest.TestCase): """Test the Home Assistant core classes.""" @@ -377,7 +386,7 @@ class TestServiceRegistry(unittest.TestCase): return ha.HomeAssistant.add_job(self, *args, **kwargs) self.services = ha.ServiceRegistry(self.bus, add_job) - self.services.register("test_domain", "test_service", lambda x: None) + self.services.register("Test_Domain", "TEST_SERVICE", lambda x: None) def tearDown(self): # pylint: disable=invalid-name """Stop down stuff we started.""" @@ -387,7 +396,7 @@ class TestServiceRegistry(unittest.TestCase): def test_has_service(self): """Test has_service method.""" self.assertTrue( - self.services.has_service("test_domain", "test_service")) + self.services.has_service("tesT_domaiN", "tesT_servicE")) self.assertFalse( self.services.has_service("test_domain", "non_existing")) self.assertFalse( @@ -409,7 +418,7 @@ class TestServiceRegistry(unittest.TestCase): lambda x: calls.append(1)) self.assertTrue( - self.services.call('test_domain', 'register_calls', blocking=True)) + self.services.call('test_domain', 'REGISTER_CALLS', blocking=True)) self.assertEqual(1, len(calls)) def test_call_with_blocking_not_done_in_time(self): @@ -442,79 +451,26 @@ class TestConfig(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """Setup things to be run when tests are started.""" self.config = ha.Config() - - def test_config_dir_set_correct(self): - """Test config dir set correct.""" - data_dir = os.getenv('APPDATA') if os.name == "nt" \ - else os.path.expanduser('~') - self.assertEqual(os.path.join(data_dir, ".homeassistant"), - self.config.config_dir) + self.assertIsNone(self.config.config_dir) def test_path_with_file(self): """Test get_config_path method.""" - data_dir = os.getenv('APPDATA') if os.name == "nt" \ - else os.path.expanduser('~') - self.assertEqual(os.path.join(data_dir, ".homeassistant", "test.conf"), + self.config.config_dir = '/tmp/ha-config' + self.assertEqual("/tmp/ha-config/test.conf", self.config.path("test.conf")) def test_path_with_dir_and_file(self): """Test get_config_path method.""" - data_dir = os.getenv('APPDATA') if os.name == "nt" \ - else os.path.expanduser('~') - self.assertEqual( - os.path.join(data_dir, ".homeassistant", "dir", "test.conf"), - self.config.path("dir", "test.conf")) - - def test_temperature_not_convert_if_no_preference(self): - """No unit conversion to happen if no preference.""" - self.assertEqual( - (25, TEMP_CELSIUS), - self.config.temperature(25, TEMP_CELSIUS)) - self.assertEqual( - (80, TEMP_FAHRENHEIT), - self.config.temperature(80, TEMP_FAHRENHEIT)) - - def test_temperature_not_convert_if_invalid_value(self): - """No unit conversion to happen if no preference.""" - self.config.temperature_unit = TEMP_FAHRENHEIT - self.assertEqual( - ('25a', TEMP_CELSIUS), - self.config.temperature('25a', TEMP_CELSIUS)) - - def test_temperature_not_convert_if_invalid_unit(self): - """No unit conversion to happen if no preference.""" - self.assertEqual( - (25, 'Invalid unit'), - self.config.temperature(25, 'Invalid unit')) - - def test_temperature_to_convert_to_celsius(self): - """Test temperature conversion to celsius.""" - self.config.temperature_unit = TEMP_CELSIUS - - self.assertEqual( - (25, TEMP_CELSIUS), - self.config.temperature(25, TEMP_CELSIUS)) - self.assertEqual( - (26.7, TEMP_CELSIUS), - self.config.temperature(80, TEMP_FAHRENHEIT)) - - def test_temperature_to_convert_to_fahrenheit(self): - """Test temperature conversion to fahrenheit.""" - self.config.temperature_unit = TEMP_FAHRENHEIT - - self.assertEqual( - (77, TEMP_FAHRENHEIT), - self.config.temperature(25, TEMP_CELSIUS)) - self.assertEqual( - (80, TEMP_FAHRENHEIT), - self.config.temperature(80, TEMP_FAHRENHEIT)) + self.config.config_dir = '/tmp/ha-config' + self.assertEqual("/tmp/ha-config/dir/test.conf", + self.config.path("dir", "test.conf")) def test_as_dict(self): """Test as dict.""" expected = { 'latitude': None, 'longitude': None, - 'temperature_unit': None, + CONF_UNIT_SYSTEM: METRIC_SYSTEM.as_dict(), 'location_name': None, 'time_zone': 'UTC', 'components': [], diff --git a/tests/test_remote.py b/tests/test_remote.py index 8820d01b9be..a5a7c0aa2d6 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -10,7 +10,8 @@ import homeassistant.components.http as http from homeassistant.const import HTTP_HEADER_HA_AUTH, EVENT_STATE_CHANGED import homeassistant.util.dt as dt_util -from tests.common import get_test_instance_port, get_test_home_assistant +from tests.common import ( + get_test_instance_port, get_test_home_assistant, get_test_config_dir) API_PASSWORD = "test1234" MASTER_PORT = get_test_instance_port() @@ -20,7 +21,7 @@ HTTP_BASE_URL = "http://127.0.0.1:{}".format(MASTER_PORT) HA_HEADERS = {HTTP_HEADER_HA_AUTH: API_PASSWORD} -broken_api = remote.API('127.0.0.1', BROKEN_PORT) +broken_api = remote.API('127.0.0.1', "bladiebla") hass, slave, master_api = None, None, None @@ -41,7 +42,7 @@ def setUpModule(): # pylint: disable=invalid-name bootstrap.setup_component( hass, http.DOMAIN, {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD, - http.CONF_SERVER_PORT: MASTER_PORT}}) + http.CONF_SERVER_PORT: MASTER_PORT}}) bootstrap.setup_component(hass, 'api') @@ -52,10 +53,11 @@ def setUpModule(): # pylint: disable=invalid-name # Start slave slave = remote.HomeAssistant(master_api) + slave.config.config_dir = get_test_config_dir() bootstrap.setup_component( slave, http.DOMAIN, {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD, - http.CONF_SERVER_PORT: SLAVE_PORT}}) + http.CONF_SERVER_PORT: SLAVE_PORT}}) slave.start() @@ -107,7 +109,7 @@ class TestRemoteMethods(unittest.TestCase): """Helper method that will verify our event got called.""" test_value.append(1) - hass.bus.listen_once("test.event_no_data", listener) + hass.bus.listen("test.event_no_data", listener) remote.fire_event(master_api, "test.event_no_data") hass.pool.block_till_done() self.assertEqual(1, len(test_value)) @@ -148,7 +150,7 @@ class TestRemoteMethods(unittest.TestCase): self.assertFalse(remote.set_state(broken_api, 'test.test', 'set_test')) def test_set_state_with_push(self): - """TestPython API set_state with push option.""" + """Test Python API set_state with push option.""" events = [] hass.bus.listen(EVENT_STATE_CHANGED, events.append) @@ -257,11 +259,13 @@ class TestRemoteClasses(unittest.TestCase): """Remove statemachine from master.""" hass.states.set("remote.master_remove", "remove me!") hass.pool.block_till_done() + slave.pool.block_till_done() self.assertIn('remote.master_remove', slave.states.entity_ids()) hass.states.remove("remote.master_remove") hass.pool.block_till_done() + slave.pool.block_till_done() self.assertNotIn('remote.master_remove', slave.states.entity_ids()) @@ -280,13 +284,11 @@ class TestRemoteClasses(unittest.TestCase): def test_eventbus_fire(self): """Test if events fired from the eventbus get fired.""" - test_value = [] + hass_call = [] + slave_call = [] - def listener(event): - """Helper method that will verify our event got called.""" - test_value.append(1) - - slave.bus.listen_once("test.event_no_data", listener) + hass.bus.listen("test.event_no_data", lambda _: hass_call.append(1)) + slave.bus.listen("test.event_no_data", lambda _: slave_call.append(1)) slave.bus.fire("test.event_no_data") # Wait till slave tells master @@ -294,7 +296,8 @@ class TestRemoteClasses(unittest.TestCase): # Wait till master gives updated event hass.pool.block_till_done() - self.assertEqual(1, len(test_value)) + self.assertEqual(1, len(hass_call)) + self.assertEqual(1, len(slave_call)) def test_get_config(self): """Test the return of the configuration.""" diff --git a/tests/util/test_distance.py b/tests/util/test_distance.py new file mode 100644 index 00000000000..7f04f6f0569 --- /dev/null +++ b/tests/util/test_distance.py @@ -0,0 +1,91 @@ +"""Test homeasssitant distance utility functions.""" + +import unittest +import homeassistant.util.distance as distance_util +from homeassistant.const import (LENGTH_KILOMETERS, LENGTH_METERS, LENGTH_FEET, + LENGTH_MILES) + +INVALID_SYMBOL = 'bob' +VALID_SYMBOL = LENGTH_KILOMETERS + + +class TestDistanceUtil(unittest.TestCase): + """Test the distance utility functions.""" + + def test_convert_same_unit(self): + """Test conversion from any unit to same unit.""" + self.assertEqual(5, + distance_util.convert(5, LENGTH_KILOMETERS, + LENGTH_KILOMETERS)) + self.assertEqual(2, + distance_util.convert(2, LENGTH_METERS, + LENGTH_METERS)) + self.assertEqual(10, + distance_util.convert(10, LENGTH_MILES, LENGTH_MILES)) + self.assertEqual(9, + distance_util.convert(9, LENGTH_FEET, LENGTH_FEET)) + + def test_convert_invalid_unit(self): + """Test exception is thrown for invalid units.""" + with self.assertRaises(ValueError): + distance_util.convert(5, INVALID_SYMBOL, + VALID_SYMBOL) + + with self.assertRaises(ValueError): + distance_util.convert(5, VALID_SYMBOL, + INVALID_SYMBOL) + + def test_convert_nonnumeric_value(self): + """Test exception is thrown for nonnumeric type.""" + with self.assertRaises(TypeError): + distance_util.convert('a', LENGTH_KILOMETERS, LENGTH_METERS) + + def test_convert_from_miles(self): + """Test conversion from miles to other units.""" + miles = 5 + self.assertEqual( + distance_util.convert(miles, LENGTH_MILES, LENGTH_KILOMETERS), + 8.04672) + self.assertEqual( + distance_util.convert(miles, LENGTH_MILES, LENGTH_METERS), + 8046.72) + self.assertEqual( + distance_util.convert(miles, LENGTH_MILES, LENGTH_FEET), + 26400.0008448) + + def test_convert_from_feet(self): + """Test conversion from feet to other units.""" + feet = 5000 + self.assertEqual( + distance_util.convert(feet, LENGTH_FEET, LENGTH_KILOMETERS), + 1.524) + self.assertEqual( + distance_util.convert(feet, LENGTH_FEET, LENGTH_METERS), + 1524) + self.assertEqual( + distance_util.convert(feet, LENGTH_FEET, LENGTH_MILES), + 0.9469694040000001) + + def test_convert_from_kilometers(self): + """Test conversion from kilometers to other units.""" + km = 5 + self.assertEqual( + distance_util.convert(km, LENGTH_KILOMETERS, LENGTH_FEET), + 16404.2) + self.assertEqual( + distance_util.convert(km, LENGTH_KILOMETERS, LENGTH_METERS), + 5000) + self.assertEqual( + distance_util.convert(km, LENGTH_KILOMETERS, LENGTH_MILES), + 3.106855) + + def test_convert_from_meters(self): + """Test conversion from meters to other units.""" + m = 5000 + self.assertEqual(distance_util.convert(m, LENGTH_METERS, LENGTH_FEET), + 16404.2) + self.assertEqual( + distance_util.convert(m, LENGTH_METERS, LENGTH_KILOMETERS), + 5) + self.assertEqual(distance_util.convert(m, LENGTH_METERS, LENGTH_MILES), + 3.106855) diff --git a/tests/util/test_location.py b/tests/util/test_location.py index 1dfb71a87bf..6d099ebcfac 100644 --- a/tests/util/test_location.py +++ b/tests/util/test_location.py @@ -79,7 +79,7 @@ class TestLocationUtil(TestCase): assert info.time_zone == 'America/Los_Angeles' assert info.latitude == 32.8594 assert info.longitude == -117.2073 - assert info.use_fahrenheit + assert not info.use_metric @requests_mock.Mocker() @patch('homeassistant.util.location._get_freegeoip', return_value=None) @@ -101,7 +101,7 @@ class TestLocationUtil(TestCase): assert info.time_zone == 'America/Los_Angeles' assert info.latitude == 32.8594 assert info.longitude == -117.2073 - assert info.use_fahrenheit + assert not info.use_metric @patch('homeassistant.util.location.elevation', return_value=0) @patch('homeassistant.util.location._get_freegeoip', return_value=None) diff --git a/tests/util/test_package.py b/tests/util/test_package.py index a4e00196959..3aa742516e4 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -3,7 +3,7 @@ import os import tempfile import unittest -import homeassistant.bootstrap as bootstrap +from homeassistant.bootstrap import mount_local_lib_path import homeassistant.util.package as package RESOURCE_DIR = os.path.abspath( @@ -21,7 +21,7 @@ class TestPackageUtil(unittest.TestCase): def setUp(self): """Create local library for testing.""" self.tmp_dir = tempfile.TemporaryDirectory() - self.lib_dir = os.path.join(self.tmp_dir.name, 'deps') + self.lib_dir = mount_local_lib_path(self.tmp_dir.name) def tearDown(self): """Stop everything that was started.""" @@ -49,8 +49,6 @@ class TestPackageUtil(unittest.TestCase): self.assertTrue(package.check_package_exists( TEST_NEW_REQ, self.lib_dir)) - bootstrap._mount_local_lib_path(self.tmp_dir.name) - try: import pyhelloworld3 except ImportError: diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py new file mode 100644 index 00000000000..c99c2cf87bf --- /dev/null +++ b/tests/util/test_unit_system.py @@ -0,0 +1,132 @@ +"""Test the unit system helper.""" +import unittest + +from homeassistant.util.unit_system import ( + UnitSystem, + METRIC_SYSTEM, + IMPERIAL_SYSTEM, +) +from homeassistant.const import ( + LENGTH_METERS, + LENGTH_KILOMETERS, + MASS_GRAMS, + VOLUME_LITERS, + TEMP_CELSIUS, + LENGTH, + MASS, + TEMPERATURE, + VOLUME +) + +SYSTEM_NAME = 'TEST' +INVALID_UNIT = 'INVALID' + + +class TestUnitSystem(unittest.TestCase): + """Test the unit system helper.""" + + def test_invalid_units(self): + """Test errors are raised when invalid units are passed in.""" + with self.assertRaises(ValueError): + UnitSystem(SYSTEM_NAME, INVALID_UNIT, LENGTH_METERS, VOLUME_LITERS, + MASS_GRAMS) + + with self.assertRaises(ValueError): + UnitSystem(SYSTEM_NAME, TEMP_CELSIUS, INVALID_UNIT, VOLUME_LITERS, + MASS_GRAMS) + + with self.assertRaises(ValueError): + UnitSystem(SYSTEM_NAME, TEMP_CELSIUS, LENGTH_METERS, INVALID_UNIT, + MASS_GRAMS) + + with self.assertRaises(ValueError): + UnitSystem(SYSTEM_NAME, TEMP_CELSIUS, LENGTH_METERS, VOLUME_LITERS, + INVALID_UNIT) + + def test_invalid_value(self): + """Test no conversion happens if value is non-numeric.""" + with self.assertRaises(TypeError): + METRIC_SYSTEM.length('25a', LENGTH_KILOMETERS) + with self.assertRaises(TypeError): + METRIC_SYSTEM.temperature('50K', TEMP_CELSIUS) + + def test_as_dict(self): + """Test that the as_dict() method returns the expected dictionary.""" + expected = { + LENGTH: LENGTH_KILOMETERS, + TEMPERATURE: TEMP_CELSIUS, + VOLUME: VOLUME_LITERS, + MASS: MASS_GRAMS + } + + self.assertEqual(expected, METRIC_SYSTEM.as_dict()) + + def test_temperature_same_unit(self): + """Test no conversion happens if to unit is same as from unit.""" + self.assertEqual( + 5, + METRIC_SYSTEM.temperature(5, + METRIC_SYSTEM.temperature_unit)) + + def test_temperature_unknown_unit(self): + """Test no conversion happens if unknown unit.""" + with self.assertRaises(ValueError): + METRIC_SYSTEM.temperature(5, 'K') + + def test_temperature_to_metric(self): + """Test temperature conversion to metric system.""" + self.assertEqual( + 25, + METRIC_SYSTEM.temperature(25, METRIC_SYSTEM.temperature_unit)) + self.assertEqual( + 26.7, + METRIC_SYSTEM.temperature(80, IMPERIAL_SYSTEM.temperature_unit)) + + def test_temperature_to_imperial(self): + """Test temperature conversion to imperial system.""" + self.assertEqual( + 77, + IMPERIAL_SYSTEM.temperature(77, IMPERIAL_SYSTEM.temperature_unit)) + self.assertEqual( + 77, + IMPERIAL_SYSTEM.temperature(25, METRIC_SYSTEM.temperature_unit)) + + def test_length_unknown_unit(self): + """Test length conversion with unknown from unit.""" + with self.assertRaises(ValueError): + METRIC_SYSTEM.length(5, 'fr') + + def test_length_to_metric(self): + """Test length conversion to metric system.""" + self.assertEqual( + 100, + METRIC_SYSTEM.length(100, METRIC_SYSTEM.length_unit) + ) + self.assertEqual( + 8.04672, + METRIC_SYSTEM.length(5, IMPERIAL_SYSTEM.length_unit) + ) + + def test_length_to_imperial(self): + """Test length conversion to imperial system.""" + self.assertEqual( + 100, + IMPERIAL_SYSTEM.length(100, + IMPERIAL_SYSTEM.length_unit) + ) + self.assertEqual( + 3.106855, + IMPERIAL_SYSTEM.length(5, METRIC_SYSTEM.length_unit) + ) + + def test_properties(self): + """Test the unit properties are returned as expected.""" + self.assertEqual(LENGTH_KILOMETERS, METRIC_SYSTEM.length_unit) + self.assertEqual(TEMP_CELSIUS, METRIC_SYSTEM.temperature_unit) + self.assertEqual(MASS_GRAMS, METRIC_SYSTEM.mass_unit) + self.assertEqual(VOLUME_LITERS, METRIC_SYSTEM.volume_unit) + + def test_is_metric(self): + """Test the is metric flag.""" + self.assertTrue(METRIC_SYSTEM.is_metric) + self.assertFalse(IMPERIAL_SYSTEM.is_metric) diff --git a/virtualization/Docker/Dockerfile.test b/virtualization/Docker/Dockerfile.test new file mode 100644 index 00000000000..6b9730bd8f4 --- /dev/null +++ b/virtualization/Docker/Dockerfile.test @@ -0,0 +1,32 @@ +FROM python:3.4 +MAINTAINER Paulus Schoutsen + +VOLUME /config + +RUN mkdir -p /usr/src/app +WORKDIR /usr/src/app + +RUN pip3 install --no-cache-dir colorlog cython + +# For the nmap tracker, bluetooth tracker, Z-Wave +RUN apt-get update && \ + apt-get install -y --no-install-recommends nmap net-tools cython3 libudev-dev sudo libglib2.0-dev locales-all && \ + apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +RUN pip3 install --no-cache-dir tox + +# Copy over everything required to run tox +COPY requirements_all.txt requirements_all.txt +COPY requirements_test.txt requirements_test.txt +COPY setup.cfg setup.cfg +COPY setup.py setup.py +COPY tox.ini tox.ini +COPY homeassistant/const.py homeassistant/const.py + +# Get deps +RUN tox --notest + +# Copy source and run tests +COPY . . + +CMD [ "tox" ]