diff --git a/.coveragerc b/.coveragerc index d6efadec3d5f5..e8666da60f446 100644 --- a/.coveragerc +++ b/.coveragerc @@ -78,6 +78,9 @@ omit = homeassistant/components/enocean.py homeassistant/components/*/enocean.py + homeassistant/components/netatmo.py + homeassistant/components/*/netatmo.py + homeassistant/components/alarm_control_panel/alarmdotcom.py homeassistant/components/alarm_control_panel/nx584.py homeassistant/components/binary_sensor/arest.py @@ -92,6 +95,7 @@ omit = homeassistant/components/device_tracker/aruba.py homeassistant/components/device_tracker/asuswrt.py homeassistant/components/device_tracker/bluetooth_tracker.py + homeassistant/components/device_tracker/bt_home_hub_5.py homeassistant/components/device_tracker/ddwrt.py homeassistant/components/device_tracker/fritz.py homeassistant/components/device_tracker/icloud.py @@ -126,6 +130,7 @@ omit = homeassistant/components/media_player/mpd.py homeassistant/components/media_player/onkyo.py homeassistant/components/media_player/panasonic_viera.py + homeassistant/components/media_player/pandora.py homeassistant/components/media_player/pioneer.py homeassistant/components/media_player/plex.py homeassistant/components/media_player/roku.py @@ -171,16 +176,18 @@ omit = homeassistant/components/sensor/gtfs.py homeassistant/components/sensor/lastfm.py homeassistant/components/sensor/loopenergy.py - homeassistant/components/sensor/netatmo.py homeassistant/components/sensor/neurio_energy.py homeassistant/components/sensor/nzbget.py homeassistant/components/sensor/onewire.py homeassistant/components/sensor/openweathermap.py + homeassistant/components/sensor/plex.py homeassistant/components/sensor/rest.py homeassistant/components/sensor/sabnzbd.py + homeassistant/components/sensor/snmp.py homeassistant/components/sensor/speedtest.py homeassistant/components/sensor/steam_online.py homeassistant/components/sensor/supervisord.py + homeassistant/components/sensor/swiss_hydrological_data.py homeassistant/components/sensor/swiss_public_transport.py homeassistant/components/sensor/systemmonitor.py homeassistant/components/sensor/temper.py @@ -196,6 +203,7 @@ omit = homeassistant/components/switch/edimax.py homeassistant/components/switch/hikvisioncam.py homeassistant/components/switch/mystrom.py + homeassistant/components/switch/netio.py homeassistant/components/switch/orvibo.py homeassistant/components/switch/pulseaudio_loopback.py homeassistant/components/switch/rest.py diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000000000..ac5a5b50599aa --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,567 @@ +swagger: '2.0' +info: + title: Home Assistant + description: Home Assistant REST API + version: "1.0.0" +# the domain of the service +host: localhost:8123 + +# array of all schemes that your API supports +schemes: + - http + - https + +securityDefinitions: + api_key: + type: apiKey + description: API password + name: api_password + in: query + + # api_key: + # type: apiKey + # description: API password + # name: x-ha-access + # in: header + +# will be prefixed to all paths +basePath: /api + +consumes: + - application/json +produces: + - application/json +paths: + /: + get: + summary: API alive message + description: Returns message if API is up and running. + tags: + - Core + responses: + 200: + description: API is up and running + schema: + $ref: '#/definitions/Message' + default: + description: Error + schema: + $ref: '#/definitions/Message' + /config: + get: + summary: API alive message + description: Returns the current configuration as JSON. + tags: + - Core + responses: + 200: + description: Current configuration + schema: + $ref: '#/definitions/ApiConfig' + default: + description: Error + schema: + $ref: '#/definitions/Message' + /discovery_info: + get: + summary: Basic information about Home Assistant instance + tags: + - Core + responses: + 200: + description: Basic information + schema: + $ref: '#/definitions/DiscoveryInfo' + default: + description: Error + schema: + $ref: '#/definitions/Message' + /bootstrap: + get: + summary: Returns all data needed to bootstrap Home Assistant. + tags: + - Core + responses: + 200: + description: Bootstrap information + schema: + $ref: '#/definitions/BootstrapInfo' + default: + description: Error + schema: + $ref: '#/definitions/Message' + /events: + get: + summary: Array of event objects. + description: Returns an array of event objects. Each event object contain event name and listener count. + tags: + - Events + responses: + 200: + description: Events + schema: + type: array + items: + $ref: '#/definitions/Event' + default: + description: Error + schema: + $ref: '#/definitions/Message' + /services: + get: + summary: Array of service objects. + description: Returns an array of service objects. Each object contains the domain and which services it contains. + tags: + - Services + responses: + 200: + description: Services + schema: + type: array + items: + $ref: '#/definitions/Service' + default: + description: Error + schema: + $ref: '#/definitions/Message' + /history: + get: + summary: Array of state changes in the past. + description: Returns an array of state changes in the past. Each object contains further detail for the entities. + tags: + - State + responses: + 200: + description: State changes + schema: + type: array + items: + $ref: '#/definitions/History' + default: + description: Error + schema: + $ref: '#/definitions/Message' + /states: + get: + summary: Array of state objects. + description: | + Returns an array of state objects. Each state has the following attributes: entity_id, state, last_changed and attributes. + tags: + - State + responses: + 200: + description: States + schema: + type: array + items: + $ref: '#/definitions/State' + default: + description: Error + schema: + $ref: '#/definitions/Message' + /states/{entity_id}: + get: + summary: Specific state object. + description: | + Returns a state object for specified entity_id. + tags: + - State + parameters: + - name: entity_id + in: path + description: entity_id of the entity to query + required: true + type: string + responses: + 200: + description: State + schema: + $ref: '#/definitions/State' + 404: + description: Not found + schema: + $ref: '#/definitions/Message' + default: + description: Error + schema: + $ref: '#/definitions/Message' + post: + description: | + Updates or creates the current state of an entity. + tags: + - State + consumes: + - application/json + parameters: + - name: entity_id + in: path + description: entity_id to set the state of + required: true + type: string + - $ref: '#/parameters/State' + responses: + 200: + description: State of existing entity was set + schema: + $ref: '#/definitions/State' + 201: + description: State of new entity was set + schema: + $ref: '#/definitions/State' + headers: + location: + type: string + description: location of the new entity + default: + description: Error + schema: + $ref: '#/definitions/Message' + /error_log: + get: + summary: Error log + description: | + Retrieve all errors logged during the current session of Home Assistant as a plaintext response. + tags: + - Core + produces: + - text/plain + responses: + 200: + description: Plain text error log + default: + description: Error + schema: + $ref: '#/definitions/Message' + /camera_proxy/camera.{entity_id}: + get: + summary: Camera image. + description: | + Returns the data (image) from the specified camera entity_id. + tags: + - Camera + produces: + - image/jpeg + parameters: + - name: entity_id + in: path + description: entity_id of the camera to query + required: true + type: string + responses: + 200: + description: Camera image + schema: + type: file + default: + description: Error + schema: + $ref: '#/definitions/Message' + /events/{event_type}: + post: + description: | + Fires an event with event_type + tags: + - Events + consumes: + - application/json + parameters: + - name: event_type + in: path + description: event_type to fire event with + required: true + type: string + - $ref: '#/parameters/EventData' + responses: + 200: + description: Response message + schema: + $ref: '#/definitions/Message' + default: + description: Error + schema: + $ref: '#/definitions/Message' + /services/{domain}/{service}: + post: + description: | + Calls a service within a specific domain. Will return when the service has been executed or 10 seconds has past, whichever comes first. + tags: + - Services + consumes: + - application/json + parameters: + - name: domain + in: path + description: domain of the service + required: true + type: string + - name: service + in: path + description: service to call + required: true + type: string + - $ref: '#/parameters/ServiceData' + responses: + 200: + description: List of states that have changed while the service was being executed. The result will include any changed states that changed while the service was being executed, even if their change was the result of something else happening in the system. + schema: + type: array + items: + $ref: '#/definitions/State' + default: + description: Error + schema: + $ref: '#/definitions/Message' + /template: + post: + description: | + Render a Home Assistant template. + tags: + - Template + consumes: + - application/json + produces: + - text/plain + parameters: + - $ref: '#/parameters/Template' + responses: + 200: + description: Returns the rendered template in plain text. + schema: + type: string + default: + description: Error + schema: + $ref: '#/definitions/Message' + /event_forwarding: + post: + description: | + Setup event forwarding to another Home Assistant instance. + tags: + - Core + consumes: + - application/json + parameters: + - $ref: '#/parameters/EventForwarding' + responses: + 200: + description: It will return a message if event forwarding was setup successful. + schema: + $ref: '#/definitions/Message' + default: + description: Error + schema: + $ref: '#/definitions/Message' + delete: + description: | + Cancel event forwarding to another Home Assistant instance. + tags: + - Core + consumes: + - application/json + parameters: + - $ref: '#/parameters/EventForwarding' + responses: + 200: + description: It will return a message if event forwarding was cancelled successful. + schema: + $ref: '#/definitions/Message' + default: + description: Error + schema: + $ref: '#/definitions/Message' + /stream: + get: + summary: Server-sent events + description: The server-sent events feature is a one-way channel from your Home Assistant server to a client which is acting as a consumer. + tags: + - Core + - Events + produces: + - text/event-stream + parameters: + - name: restrict + in: query + description: comma-separated list of event_types to filter + required: false + type: string + responses: + default: + description: Stream of events + schema: + type: object + x-events: + state_changed: + type: object + properties: + entity_id: + type: string + old_state: + $ref: '#/definitions/State' + new_state: + $ref: '#/definitions/State' +definitions: + ApiConfig: + type: object + properties: + components: + type: array + description: List of component types + items: + type: string + description: Component type + latitude: + type: number + format: float + description: Latitude of Home Assistant server + longitude: + type: number + format: float + description: Longitude of Home Assistant server + location_name: + type: string + temperature_unit: + type: string + time_zone: + type: string + version: + type: string + DiscoveryInfo: + type: object + properties: + base_url: + type: string + location_name: + type: string + requires_api_password: + type: boolean + version: + type: string + BootstrapInfo: + type: object + properties: + config: + $ref: '#/definitions/ApiConfig' + events: + type: array + items: + $ref: '#/definitions/Event' + services: + type: array + items: + $ref: '#/definitions/Service' + states: + type: array + items: + $ref: '#/definitions/State' + Event: + type: object + properties: + event: + type: string + listener_count: + type: integer + Service: + type: object + properties: + domain: + type: string + services: + type: object + additionalProperties: + $ref: '#/definitions/DomainService' + DomainService: + type: object + properties: + description: + type: string + fields: + type: object + description: Object with service fields that can be called + State: + type: object + properties: + attributes: + $ref: '#/definitions/StateAttributes' + state: + type: string + entity_id: + type: string + last_changed: + type: string + format: date-time + StateAttributes: + type: object + additionalProperties: + type: string + History: + allOf: + - $ref: '#/definitions/State' + - type: object + properties: + last_updated: + type: string + format: date-time + Message: + type: object + properties: + message: + type: string +parameters: + State: + name: body + in: body + description: State parameter + required: false + schema: + type: object + required: + - state + properties: + attributes: + $ref: '#/definitions/StateAttributes' + state: + type: string + EventData: + name: body + in: body + description: event_data + required: false + schema: + type: object + ServiceData: + name: body + in: body + description: service_data + required: false + schema: + type: object + Template: + name: body + in: body + description: Template to render + required: true + schema: + type: object + required: + - template + properties: + template: + description: Jinja2 template string + type: string + EventForwarding: + name: body + in: body + description: Event Forwarding parameter + required: true + schema: + type: object + required: + - host + - api_password + properties: + host: + type: string + api_password: + type: string + port: + type: integer diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index cf042abbe10f2..29b2d47fd208e 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -9,7 +9,6 @@ import voluptuous as vol -from homeassistant.components import verisure from homeassistant.const import ( ATTR_CODE, ATTR_CODE_FORMAT, ATTR_ENTITY_ID, SERVICE_ALARM_TRIGGER, SERVICE_ALARM_DISARM, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_AWAY) @@ -24,11 +23,6 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' -# Maps discovered services to their platforms -DISCOVERY_PLATFORMS = { - verisure.DISCOVER_ALARMS: 'verisure' -} - SERVICE_TO_METHOD = { SERVICE_ALARM_DISARM: 'alarm_disarm', SERVICE_ALARM_ARM_HOME: 'alarm_arm_home', @@ -50,8 +44,7 @@ def setup(hass, config): """Track states and offer events for sensors.""" component = EntityComponent( - logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL, - DISCOVERY_PLATFORMS) + logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) component.setup(config) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 8d941166fddf9..18cb8ea0cb22a 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -9,8 +9,6 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import Entity from homeassistant.const import (STATE_ON, STATE_OFF) -from homeassistant.components import ( - bloomsky, mysensors, zwave, vera, wemo, wink) from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa DOMAIN = 'binary_sensor' @@ -35,22 +33,11 @@ 'vibration', # On means vibration detected, Off means no vibration ] -# Maps discovered services to their platforms -DISCOVERY_PLATFORMS = { - bloomsky.DISCOVER_BINARY_SENSORS: 'bloomsky', - mysensors.DISCOVER_BINARY_SENSORS: 'mysensors', - zwave.DISCOVER_BINARY_SENSORS: 'zwave', - vera.DISCOVER_BINARY_SENSORS: 'vera', - wemo.DISCOVER_BINARY_SENSORS: 'wemo', - wink.DISCOVER_BINARY_SENSORS: 'wink' -} - def setup(hass, config): """Track states and offer events for binary sensors.""" component = EntityComponent( - logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL, - DISCOVERY_PLATFORMS) + logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) component.setup(config) diff --git a/homeassistant/components/binary_sensor/rest.py b/homeassistant/components/binary_sensor/rest.py index d952d04d32b4e..d9a6f1d8947dd 100644 --- a/homeassistant/components/binary_sensor/rest.py +++ b/homeassistant/components/binary_sensor/rest.py @@ -35,7 +35,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): rest.update() if rest.data is None: - _LOGGER.error('Unable to fetch Rest data') + _LOGGER.error('Unable to fetch REST data') return False add_devices([RestBinarySensor( @@ -57,6 +57,7 @@ def __init__(self, hass, rest, name, sensor_class, value_template): self._name = name self._sensor_class = sensor_class self._state = False + self._previous_data = None self._value_template = value_template self.update() @@ -77,9 +78,14 @@ def is_on(self): return False if self._value_template is not None: - self.rest.data = template.render_with_possible_json_value( + response = template.render_with_possible_json_value( self._hass, self._value_template, self.rest.data, False) - return bool(int(self.rest.data)) + + try: + return bool(int(response)) + except ValueError: + return {"true": True, "on": True, "open": True, + "yes": True}.get(response.lower(), False) def update(self): """Get the latest data from REST API and updates the state.""" diff --git a/homeassistant/components/binary_sensor/wink.py b/homeassistant/components/binary_sensor/wink.py index c6989cae98c40..d9c2b7d577a36 100644 --- a/homeassistant/components/binary_sensor/wink.py +++ b/homeassistant/components/binary_sensor/wink.py @@ -10,7 +10,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, ATTR_BATTERY_LEVEL from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['python-wink==0.7.6'] +REQUIREMENTS = ['python-wink==0.7.7'] # These are the available sensors mapped to binary_sensor class SENSOR_TYPES = { diff --git a/homeassistant/components/bloomsky.py b/homeassistant/components/bloomsky.py index de9f4a18b914c..b881dcb9526f2 100644 --- a/homeassistant/components/bloomsky.py +++ b/homeassistant/components/bloomsky.py @@ -9,9 +9,8 @@ import requests -from homeassistant.components import discovery from homeassistant.const import CONF_API_KEY -from homeassistant.helpers import validate_config +from homeassistant.helpers import validate_config, discovery from homeassistant.util import Throttle DOMAIN = "bloomsky" @@ -23,10 +22,6 @@ # no point in polling the API more frequently MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) -DISCOVER_SENSORS = 'bloomsky.sensors' -DISCOVER_BINARY_SENSORS = 'bloomsky.binary_sensor' -DISCOVER_CAMERAS = 'bloomsky.camera' - # pylint: disable=unused-argument,too-few-public-methods def setup(hass, config): @@ -45,11 +40,8 @@ def setup(hass, config): except RuntimeError: return False - for component, discovery_service in ( - ('camera', DISCOVER_CAMERAS), ('sensor', DISCOVER_SENSORS), - ('binary_sensor', DISCOVER_BINARY_SENSORS)): - discovery.discover(hass, discovery_service, component=component, - hass_config=config) + for component in 'camera', 'binary_sensor', 'sensor': + discovery.load_platform(hass, component, DOMAIN, {}, config) return True diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index bc2cb1a40c99d..873425289874b 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -9,7 +9,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.components import bloomsky from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.components.http import HomeAssistantView @@ -18,11 +17,6 @@ SCAN_INTERVAL = 30 ENTITY_ID_FORMAT = DOMAIN + '.{}' -# Maps discovered services to their platforms -DISCOVERY_PLATFORMS = { - bloomsky.DISCOVER_CAMERAS: 'bloomsky', -} - STATE_RECORDING = 'recording' STATE_STREAMING = 'streaming' STATE_IDLE = 'idle' @@ -34,8 +28,7 @@ def setup(hass, config): """Setup the camera component.""" component = EntityComponent( - logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL, - DISCOVERY_PLATFORMS) + logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) hass.wsgi.register_view(CameraImageView(hass, component.entities)) hass.wsgi.register_view(CameraMjpegStream(hass, component.entities)) diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py index f8ba7dfe27dd4..95a6460b81454 100644 --- a/homeassistant/components/camera/foscam.py +++ b/homeassistant/components/camera/foscam.py @@ -49,7 +49,7 @@ def __init__(self, device_info): def camera_image(self): """Return a still image reponse from the camera.""" # Send the request to snap a picture and return raw jpg data - response = requests.get(self._snap_picture_url) + response = requests.get(self._snap_picture_url, timeout=10) return response.content diff --git a/homeassistant/components/camera/generic.py b/homeassistant/components/camera/generic.py index bad0e3af6d9e5..91f44a2a2308e 100644 --- a/homeassistant/components/camera/generic.py +++ b/homeassistant/components/camera/generic.py @@ -43,13 +43,14 @@ def camera_image(self): try: response = requests.get( self._still_image_url, - auth=HTTPBasicAuth(self._username, self._password)) + auth=HTTPBasicAuth(self._username, self._password), + timeout=10) except requests.exceptions.RequestException as error: _LOGGER.error('Error getting camera image: %s', error) return None else: try: - response = requests.get(self._still_image_url) + response = requests.get(self._still_image_url, timeout=10) except requests.exceptions.RequestException as error: _LOGGER.error('Error getting camera image: %s', error) return None diff --git a/homeassistant/components/camera/local_file.py b/homeassistant/components/camera/local_file.py new file mode 100644 index 0000000000000..463bf3eca5adf --- /dev/null +++ b/homeassistant/components/camera/local_file.py @@ -0,0 +1,53 @@ +"""Camera that loads a picture from a local file.""" + +import logging +import os + +from homeassistant.components.camera import Camera + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Camera.""" + # check for missing required configuration variable + if config.get("file_path") is None: + _LOGGER.error("Missing required variable: file_path") + return False + + setup_config = ( + { + "name": config.get("name", "Local File"), + "file_path": config.get("file_path") + } + ) + + # check filepath given is readable + if not os.access(setup_config["file_path"], os.R_OK): + _LOGGER.error("file path is not readable") + return False + + add_devices([ + LocalFile(setup_config) + ]) + + +class LocalFile(Camera): + """Local camera.""" + + def __init__(self, device_info): + """Initialize Local File Camera component.""" + super().__init__() + + self._name = device_info["name"] + self._config = device_info + + def camera_image(self): + """Return image response.""" + with open(self._config["file_path"], 'rb') as file: + return file.read() + + @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 affc4c1ccdabf..b7f31404a8f02 100644 --- a/homeassistant/components/camera/mjpeg.py +++ b/homeassistant/components/camera/mjpeg.py @@ -46,10 +46,9 @@ def camera_stream(self): return requests.get(self._mjpeg_url, auth=HTTPBasicAuth(self._username, self._password), - stream=True) + stream=True, timeout=10) else: - return requests.get(self._mjpeg_url, - stream=True) + return requests.get(self._mjpeg_url, stream=True, timeout=10) def camera_image(self): """Return a still image response from the camera.""" diff --git a/homeassistant/components/camera/netatmo.py b/homeassistant/components/camera/netatmo.py new file mode 100644 index 0000000000000..752a28f1bbcc1 --- /dev/null +++ b/homeassistant/components/camera/netatmo.py @@ -0,0 +1,104 @@ +""" +Support for the Netatmo Welcome camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.netatmo/ +""" +import logging +from datetime import timedelta +import requests +from homeassistant.util import Throttle + +from homeassistant.components.camera import Camera +from homeassistant.loader import get_component + +DEPENDENCIES = ["netatmo"] + +_LOGGER = logging.getLogger(__name__) + +CONF_HOME = 'home' +ATTR_CAMERAS = 'cameras' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup access to Netatmo Welcome cameras.""" + netatmo = get_component('netatmo') + home = config.get(CONF_HOME, None) + data = WelcomeData(netatmo.NETATMO_AUTH, home) + + for camera_name in data.get_camera_names(): + if ATTR_CAMERAS in config: + if camera_name not in config[ATTR_CAMERAS]: + continue + add_devices_callback([WelcomeCamera(data, camera_name, home)]) + + +class WelcomeCamera(Camera): + """Representation of the images published from Welcome camera.""" + + def __init__(self, data, camera_name, home): + """Setup for access to the BloomSky camera images.""" + super(WelcomeCamera, self).__init__() + self._data = data + self._camera_name = camera_name + if home: + self._name = home + ' / ' + camera_name + else: + self._name = camera_name + self._vpnurl, self._localurl = self._data.welcomedata.cameraUrls( + camera=camera_name + ) + + def camera_image(self): + """Return a still image response from the camera.""" + try: + if self._localurl: + response = requests.get('{0}/live/snapshot_720.jpg'.format( + self._localurl), timeout=10) + else: + response = requests.get('{0}/live/snapshot_720.jpg'.format( + self._vpnurl), timeout=10) + except requests.exceptions.RequestException as error: + _LOGGER.error('Welcome VPN url changed: %s', error) + self._data.update() + (self._vpnurl, self._localurl) = \ + self._data.welcomedata.cameraUrls(camera=self._camera_name) + return None + return response.content + + @property + def name(self): + """Return the name of this Netatmo Welcome device.""" + return self._name + + +class WelcomeData(object): + """Get the latest data from NetAtmo.""" + + def __init__(self, auth, home=None): + """Initialize the data object.""" + self.auth = auth + self.welcomedata = None + self.camera_names = [] + self.home = home + + def get_camera_names(self): + """Return all module available on the API as a list.""" + self.update() + if not self.home: + for home in self.welcomedata.cameras.keys(): + for camera in self.welcomedata.cameras[home].values(): + self.camera_names.append(camera['name']) + else: + for camera in self.welcomedata.cameras[self.home].values(): + self.camera_names.append(camera['name']) + return self.camera_names + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Call the NetAtmo API to update the data.""" + import lnetatmo + self.welcomedata = lnetatmo.WelcomeData(self.auth) diff --git a/homeassistant/components/configurator.py b/homeassistant/components/configurator.py index 8705f9ce07741..b7c102a584cff 100644 --- a/homeassistant/components/configurator.py +++ b/homeassistant/components/configurator.py @@ -8,7 +8,7 @@ """ import logging -from homeassistant.const import EVENT_TIME_CHANGED +from homeassistant.const import EVENT_TIME_CHANGED, ATTR_FRIENDLY_NAME from homeassistant.helpers.entity import generate_entity_id DOMAIN = "configurator" @@ -118,6 +118,7 @@ def request_config( data = { ATTR_CONFIGURE_ID: request_id, ATTR_FIELDS: fields, + ATTR_FRIENDLY_NAME: name, } data.update({ diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 8129947977201..148c57a12c345 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -67,7 +67,9 @@ def setup(hass, config): lights[1], switches[0], 'input_select.living_room_preset', 'rollershutter.living_room_window', media_players[1], 'scene.romantic_lights']) - group.Group(hass, 'bedroom', [lights[0], switches[1], media_players[0]]) + group.Group(hass, 'bedroom', [ + lights[0], switches[1], media_players[0], + 'input_slider.noise_allowance']) group.Group(hass, 'kitchen', [ lights[2], 'rollershutter.kitchen_window', 'lock.kitchen_door']) group.Group(hass, 'doors', [ @@ -145,6 +147,17 @@ def setup(hass, config): {'input_boolean': {'notify': {'icon': 'mdi:car', 'initial': False, 'name': 'Notify Anne Therese is home'}}}) + + # Set up input boolean + bootstrap.setup_component( + hass, 'input_slider', + {'input_slider': { + 'noise_allowance': {'icon': 'mdi:bell-ring', + 'min': 0, + 'max': 10, + 'name': 'Allowed Noise', + 'unit_of_measurement': 'dB'}}}) + # Set up weblink bootstrap.setup_component( hass, 'weblink', diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 55b988fde4cfd..07b4343803cf5 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -12,10 +12,11 @@ import threading from homeassistant.bootstrap import prepare_setup_platform -from homeassistant.components import discovery, group, zone +from homeassistant.components import group, zone +from homeassistant.components.discovery import SERVICE_NETGEAR from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform +from homeassistant.helpers import config_per_platform, discovery from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv import homeassistant.util as util @@ -62,7 +63,7 @@ ATTR_BATTERY = 'battery' DISCOVERY_PLATFORMS = { - discovery.SERVICE_NETGEAR: 'netgear', + SERVICE_NETGEAR: 'netgear', } _LOGGER = logging.getLogger(__name__) @@ -95,8 +96,11 @@ def setup(hass, config): yaml_path = hass.config.path(YAML_DEVICES) conf = config.get(DOMAIN, {}) - if isinstance(conf, list) and len(conf) > 0: - conf = conf[0] + + # Config can be an empty list. In that case, substitute a dict + if isinstance(conf, list): + conf = conf[0] if len(conf) > 0 else {} + consider_home = timedelta( seconds=util.convert(conf.get(CONF_CONSIDER_HOME), int, DEFAULT_CONSIDER_HOME)) diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index fb8a76a148837..282ae46ba8592 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -6,8 +6,10 @@ """ import logging import re +import socket import telnetlib import threading +from collections import namedtuple from datetime import timedelta from homeassistant.components.device_tracker import DOMAIN @@ -28,6 +30,21 @@ r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' + r'(?P([^\s]+))') +# command to get both 5GHz and 2.4GHz clients +_WL_CMD = '{ wl -i eth2 assoclist & wl -i eth1 assoclist ; }' +_WL_REGEX = re.compile( + r'\w+\s' + + r'(?P(([0-9A-F]{2}[:-]){5}([0-9A-F]{2})))') + +_ARP_CMD = 'arp -n' +_ARP_REGEX = re.compile( + r'.+\s' + + r'\((?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\)\s' + + r'.+\s' + + r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))' + + r'\s' + + r'.*') + _IP_NEIGH_CMD = 'ip neigh' _IP_NEIGH_REGEX = re.compile( r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' + @@ -41,24 +58,35 @@ def get_scanner(hass, config): """Validate the configuration and return an ASUS-WRT scanner.""" if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, + {DOMAIN: [CONF_HOST, CONF_USERNAME]}, _LOGGER): return None + elif CONF_PASSWORD not in config[DOMAIN] and \ + 'pub_key' not in config[DOMAIN]: + _LOGGER.error("Either a public key or password must be provided") + return None scanner = AsusWrtDeviceScanner(config[DOMAIN]) return scanner if scanner.success_init else None +AsusWrtResult = namedtuple('AsusWrtResult', 'neighbors leases arp') + class AsusWrtDeviceScanner(object): """This class queries a router running ASUSWRT firmware.""" + # pylint: disable=too-many-instance-attributes, too-many-branches + # Eighth attribute needed for mode (AP mode vs router mode) + def __init__(self, config): """Initialize the scanner.""" self.host = config[CONF_HOST] self.username = str(config[CONF_USERNAME]) - self.password = str(config[CONF_PASSWORD]) + self.password = str(config.get(CONF_PASSWORD)) + self.pub_key = str(config.get('pub_key')) self.protocol = config.get('protocol') + self.mode = config.get('mode') self.lock = threading.Lock() @@ -106,21 +134,40 @@ def _update_info(self): def ssh_connection(self): """Retrieve data from ASUSWRT via the ssh protocol.""" - from pexpect import pxssh + from pexpect import pxssh, exceptions + try: ssh = pxssh.pxssh() - ssh.login(self.host, self.username, self.password) + if self.pub_key: + ssh.login(self.host, self.username, ssh_key=self.pub_key) + elif self.password: + ssh.login(self.host, self.username, self.password) + else: + _LOGGER.error('No password or public key specified') + return None ssh.sendline(_IP_NEIGH_CMD) ssh.prompt() neighbors = ssh.before.split(b'\n')[1:-1] - ssh.sendline(_LEASES_CMD) - ssh.prompt() - leases_result = ssh.before.split(b'\n')[1:-1] + if self.mode == 'ap': + ssh.sendline(_ARP_CMD) + ssh.prompt() + arp_result = ssh.before.split(b'\n')[1:-1] + ssh.sendline(_WL_CMD) + ssh.prompt() + leases_result = ssh.before.split(b'\n')[1:-1] + else: + arp_result = [''] + ssh.sendline(_LEASES_CMD) + ssh.prompt() + leases_result = ssh.before.split(b'\n')[1:-1] ssh.logout() - return (neighbors, leases_result) + return AsusWrtResult(neighbors, leases_result, arp_result) except pxssh.ExceptionPxssh as exc: - _LOGGER.exception('Unexpected response from router: %s', exc) - return ('', '') + _LOGGER.error('Unexpected response from router: %s', exc) + return None + except exceptions.EOF: + _LOGGER.error('Connection refused or no route to host') + return None def telnet_connection(self): """Retrieve data from ASUSWRT via the telnet protocol.""" @@ -133,47 +180,99 @@ def telnet_connection(self): prompt_string = telnet.read_until(b'#').split(b'\n')[-1] telnet.write('{}\n'.format(_IP_NEIGH_CMD).encode('ascii')) neighbors = telnet.read_until(prompt_string).split(b'\n')[1:-1] - telnet.write('{}\n'.format(_LEASES_CMD).encode('ascii')) - leases_result = telnet.read_until(prompt_string).split(b'\n')[1:-1] + if self.mode == 'ap': + telnet.write('{}\n'.format(_ARP_CMD).encode('ascii')) + arp_result = (telnet.read_until(prompt_string). + split(b'\n')[1:-1]) + telnet.write('{}\n'.format(_WL_CMD).encode('ascii')) + leases_result = (telnet.read_until(prompt_string). + split(b'\n')[1:-1]) + else: + arp_result = [''] + telnet.write('{}\n'.format(_LEASES_CMD).encode('ascii')) + leases_result = (telnet.read_until(prompt_string). + split(b'\n')[1:-1]) telnet.write('exit\n'.encode('ascii')) - return (neighbors, leases_result) + return AsusWrtResult(neighbors, leases_result, arp_result) except EOFError: - _LOGGER.exception("Unexpected response from router") - return ('', '') + _LOGGER.error("Unexpected response from router") + return None except ConnectionRefusedError: - _LOGGER.exception("Connection refused by router," - " is telnet enabled?") - return ('', '') + _LOGGER.error("Connection refused by router, is telnet enabled?") + return None + except socket.gaierror as exc: + _LOGGER.error("Socket exception: %s", exc) + return None + except OSError as exc: + _LOGGER.error("OSError: %s", exc) + return None def get_asuswrt_data(self): """Retrieve data from ASUSWRT and return parsed result.""" - if self.protocol == 'telnet': - neighbors, leases_result = self.telnet_connection() + if self.protocol == 'ssh': + result = self.ssh_connection() + elif self.protocol == 'telnet': + result = self.telnet_connection() else: - neighbors, leases_result = self.ssh_connection() + # autodetect protocol + result = self.ssh_connection() + if result: + self.protocol = 'ssh' + else: + result = self.telnet_connection() + if result: + self.protocol = 'telnet' + + if not result: + return {} devices = {} - for lease in leases_result: - match = _LEASES_REGEX.search(lease.decode('utf-8')) + if self.mode == 'ap': + for lease in result.leases: + match = _WL_REGEX.search(lease.decode('utf-8')) - if not match: - _LOGGER.warning("Could not parse lease row: %s", lease) - continue + if not match: + _LOGGER.warning("Could not parse wl row: %s", lease) + continue - # For leases where the client doesn't set a hostname, ensure it is - # blank and not '*', which breaks the entity_id down the line. - host = match.group('host') - if host == '*': host = '' - devices[match.group('ip')] = { - 'host': host, - 'status': '', - 'ip': match.group('ip'), - 'mac': match.group('mac').upper(), - } + # match mac addresses to IP addresses in ARP table + for arp in result.arp: + if match.group('mac').lower() in arp.decode('utf-8'): + arp_match = _ARP_REGEX.search(arp.decode('utf-8')) + if not arp_match: + _LOGGER.warning("Could not parse arp row: %s", arp) + continue + + devices[arp_match.group('ip')] = { + 'host': host, + 'status': '', + 'ip': arp_match.group('ip'), + 'mac': match.group('mac').upper(), + } + else: + for lease in result.leases: + match = _LEASES_REGEX.search(lease.decode('utf-8')) + + if not match: + _LOGGER.warning("Could not parse lease row: %s", lease) + continue + + # For leases where the client doesn't set a hostname, ensure it + # is blank and not '*', which breaks entity_id down the line. + host = match.group('host') + if host == '*': + host = '' + + devices[match.group('ip')] = { + 'host': host, + 'status': '', + 'ip': match.group('ip'), + 'mac': match.group('mac').upper(), + } - for neighbor in neighbors: + for neighbor in result.neighbors: match = _IP_NEIGH_REGEX.search(neighbor.decode('utf-8')) if not match: _LOGGER.warning("Could not parse neighbor row: %s", neighbor) diff --git a/homeassistant/components/device_tracker/bt_home_hub_5.py b/homeassistant/components/device_tracker/bt_home_hub_5.py new file mode 100644 index 0000000000000..c447fae16358b --- /dev/null +++ b/homeassistant/components/device_tracker/bt_home_hub_5.py @@ -0,0 +1,141 @@ +""" +Support for BT Home Hub 5. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.bt_home_hub_5/ +""" +import logging +import re +import threading +from datetime import timedelta +import xml.etree.ElementTree as ET +import json +from urllib.parse import unquote + +import requests + +from homeassistant.helpers import validate_config +from homeassistant.components.device_tracker import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.util import Throttle + +# Return cached results if last scan was less then this time ago. +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + +_LOGGER = logging.getLogger(__name__) + +_MAC_REGEX = re.compile(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})') + + +# pylint: disable=unused-argument +def get_scanner(hass, config): + """Return a BT Home Hub 5 scanner if successful.""" + if not validate_config(config, + {DOMAIN: [CONF_HOST]}, + _LOGGER): + return None + scanner = BTHomeHub5DeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +class BTHomeHub5DeviceScanner(object): + """This class queries a BT Home Hub 5.""" + + def __init__(self, config): + """Initialise the scanner.""" + _LOGGER.info("Initialising BT Home Hub 5") + self.host = config.get(CONF_HOST, '192.168.1.254') + + self.lock = threading.Lock() + + self.last_results = {} + + self.url = 'http://{}/nonAuth/home_status.xml'.format(self.host) + + # Test the router is accessible + data = _get_homehub_data(self.url) + self.success_init = data is not None + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return (device for device in self.last_results) + + def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + with self.lock: + # If not initialised and not already scanned and not found. + if device not in self.last_results: + self._update_info() + + if not self.last_results: + return None + + return self.last_results.get(device) + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """Ensure the information from the BT Home Hub 5 is up to date. + + Return boolean if scanning successful. + """ + if not self.success_init: + return False + + with self.lock: + _LOGGER.info("Scanning") + + data = _get_homehub_data(self.url) + + if not data: + _LOGGER.warning('Error scanning devices') + return False + + self.last_results = data + + return True + + +def _get_homehub_data(url): + """Retrieve data from BT Home Hub 5 and return parsed result.""" + try: + response = requests.get(url, timeout=5) + except requests.exceptions.Timeout: + _LOGGER.exception("Connection to the router timed out") + return + if response.status_code == 200: + return _parse_homehub_response(response.text) + else: + _LOGGER.error("Invalid response from Home Hub: %s", response) + + +def _parse_homehub_response(data_str): + """Parse the BT Home Hub 5 data format.""" + root = ET.fromstring(data_str) + + dirty_json = root.find('known_device_list').get('value') + + # Normalise the JavaScript data to JSON. + clean_json = unquote(dirty_json.replace('\'', '\"') + .replace('{', '{\"') + .replace(':\"', '\":\"') + .replace('\",', '\",\"')) + + known_devices = [x for x in json.loads(clean_json) if x] + + devices = {} + + for device in known_devices: + name = device.get('name') + mac = device.get('mac') + + if _MAC_REGEX.match(mac) or ',' in mac: + for mac_addr in mac.split(','): + if _MAC_REGEX.match(mac_addr): + devices[mac_addr] = name + else: + devices[mac] = name + + return devices diff --git a/homeassistant/components/device_tracker/locative.py b/homeassistant/components/device_tracker/locative.py index 1b29e7083a200..1a0ec45730462 100644 --- a/homeassistant/components/device_tracker/locative.py +++ b/homeassistant/components/device_tracker/locative.py @@ -26,7 +26,7 @@ class LocativeView(HomeAssistantView): """View to handle locative requests.""" url = "/api/locative" - name = "api:bootstrap" + name = "api:locative" def __init__(self, hass, see): """Initialize Locative url endpoints.""" diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index fac94aacf6968..b4a865abffc9a 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -9,100 +9,30 @@ import logging import threading -from homeassistant import bootstrap -from homeassistant.const import ( - ATTR_DISCOVERED, ATTR_SERVICE, EVENT_HOMEASSISTANT_START, - EVENT_PLATFORM_DISCOVERED) +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.helpers.discovery import load_platform, discover DOMAIN = "discovery" REQUIREMENTS = ['netdisco==0.6.7'] SCAN_INTERVAL = 300 # seconds -LOAD_PLATFORM = 'load_platform' - SERVICE_WEMO = 'belkin_wemo' -SERVICE_HUE = 'philips_hue' -SERVICE_CAST = 'google_cast' SERVICE_NETGEAR = 'netgear_router' -SERVICE_SONOS = 'sonos' -SERVICE_PLEX = 'plex_mediaserver' -SERVICE_SQUEEZEBOX = 'logitech_mediaserver' -SERVICE_PANASONIC_VIERA = 'panasonic_viera' -SERVICE_ROKU = 'roku' SERVICE_HANDLERS = { - SERVICE_WEMO: "wemo", - SERVICE_CAST: "media_player", - SERVICE_HUE: "light", - SERVICE_NETGEAR: 'device_tracker', - SERVICE_SONOS: 'media_player', - SERVICE_PLEX: 'media_player', - SERVICE_SQUEEZEBOX: 'media_player', - SERVICE_PANASONIC_VIERA: 'media_player', - SERVICE_ROKU: 'media_player', + SERVICE_NETGEAR: ('device_tracker', None), + SERVICE_WEMO: ('wemo', None), + 'philips_hue': ('light', 'hue'), + 'google_cast': ('media_player', 'cast'), + 'panasonic_viera': ('media_player', 'panasonic_viera'), + 'plex_mediaserver': ('media_player', 'plex'), + 'roku': ('media_player', 'roku'), + 'sonos': ('media_player', 'sonos'), + 'logitech_mediaserver': ('media_player', 'squeezebox'), } -def listen(hass, service, callback): - """Setup listener for discovery of specific service. - - Service can be a string or a list/tuple. - """ - if isinstance(service, str): - service = (service,) - else: - service = tuple(service) - - def discovery_event_listener(event): - """Listen for discovery events.""" - if ATTR_SERVICE in event.data and event.data[ATTR_SERVICE] in service: - callback(event.data[ATTR_SERVICE], event.data.get(ATTR_DISCOVERED)) - - hass.bus.listen(EVENT_PLATFORM_DISCOVERED, discovery_event_listener) - - -def discover(hass, service, discovered=None, component=None, hass_config=None): - """Fire discovery event. Can ensure a component is loaded.""" - if component is not None: - bootstrap.setup_component(hass, component, hass_config) - - data = { - ATTR_SERVICE: service - } - - if discovered is not None: - data[ATTR_DISCOVERED] = discovered - - hass.bus.fire(EVENT_PLATFORM_DISCOVERED, data) - - -def load_platform(hass, component, platform, info=None, hass_config=None): - """Helper method for generic platform loading. - - This method allows a platform to be loaded dynamically without it being - known at runtime (in the DISCOVERY_PLATFORMS list of the component). - Advantages of using this method: - - Any component & platforms combination can be dynamically added - - A component (i.e. light) does not have to import every component - that can dynamically add a platform (e.g. wemo, wink, insteon_hub) - - Custom user components can take advantage of discovery/loading - - Target components will be loaded and an EVENT_PLATFORM_DISCOVERED will be - fired to load the platform. The event will contain: - { ATTR_SERVICE = LOAD_PLATFORM + '.' + <> - ATTR_DISCOVERED = {LOAD_PLATFORM: <>} } - - * dev note: This listener can be found in entity_component.py - """ - if info is None: - info = {LOAD_PLATFORM: platform} - else: - info[LOAD_PLATFORM] = platform - discover(hass, LOAD_PLATFORM + '.' + component, info, component, - hass_config) - - def setup(hass, config): """Start a discovery service.""" logger = logging.getLogger(__name__) @@ -119,20 +49,18 @@ def new_service_listener(service, info): with lock: logger.info("Found new service: %s %s", service, info) - component = SERVICE_HANDLERS.get(service) + comp_plat = SERVICE_HANDLERS.get(service) # We do not know how to handle this service. - if not component: + if not comp_plat: return - # This component cannot be setup. - if not bootstrap.setup_component(hass, component, config): - return + component, platform = comp_plat - hass.bus.fire(EVENT_PLATFORM_DISCOVERED, { - ATTR_SERVICE: service, - ATTR_DISCOVERED: info - }) + if platform is None: + discover(hass, service, info, component, config) + else: + load_platform(hass, component, platform, info, config) # pylint: disable=unused-argument def start_discovery(event): diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index 11c49fd44eb55..48d689364eeb9 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -8,21 +8,18 @@ import os from datetime import timedelta -from homeassistant import bootstrap -from homeassistant.const import ( - ATTR_DISCOVERED, ATTR_SERVICE, CONF_API_KEY, EVENT_PLATFORM_DISCOVERED) +from homeassistant.helpers import discovery +from homeassistant.const import CONF_API_KEY from homeassistant.loader import get_component from homeassistant.util import Throttle DOMAIN = "ecobee" -DISCOVER_THERMOSTAT = "ecobee.thermostat" -DISCOVER_SENSORS = "ecobee.sensor" NETWORK = None HOLD_TEMP = 'hold_temp' REQUIREMENTS = [ 'https://github.com/nkgilley/python-ecobee-api/archive/' - '4a884bc146a93991b4210f868f3d6aecf0a181e6.zip#python-ecobee==0.0.5'] + '4856a704670c53afe1882178a89c209b5f98533d.zip#python-ecobee==0.0.6'] _LOGGER = logging.getLogger(__name__) @@ -70,23 +67,11 @@ def setup_ecobee(hass, network, config): configurator = get_component('configurator') configurator.request_done(_CONFIGURING.pop('ecobee')) - # Ensure component is loaded - bootstrap.setup_component(hass, 'thermostat', config) - bootstrap.setup_component(hass, 'sensor', config) - hold_temp = config[DOMAIN].get(HOLD_TEMP, False) - # Fire thermostat discovery event - hass.bus.fire(EVENT_PLATFORM_DISCOVERED, { - ATTR_SERVICE: DISCOVER_THERMOSTAT, - ATTR_DISCOVERED: {'hold_temp': hold_temp} - }) - - # Fire sensor discovery event - hass.bus.fire(EVENT_PLATFORM_DISCOVERED, { - ATTR_SERVICE: DISCOVER_SENSORS, - ATTR_DISCOVERED: {} - }) + discovery.load_platform(hass, 'thermostat', DOMAIN, + {'hold_temp': hold_temp}, config) + discovery.load_platform(hass, 'sensor', DOMAIN, {}, config) # pylint: disable=too-few-public-methods diff --git a/homeassistant/components/frontend/www_static/core.js.gz b/homeassistant/components/frontend/www_static/core.js.gz index 1d33c1fde0268..1944a0e7db9f8 100644 Binary files a/homeassistant/components/frontend/www_static/core.js.gz and b/homeassistant/components/frontend/www_static/core.js.gz differ diff --git a/homeassistant/components/frontend/www_static/favicon-1024x1024.png b/homeassistant/components/frontend/www_static/favicon-1024x1024.png new file mode 100644 index 0000000000000..4bcc7924726b1 Binary files /dev/null and b/homeassistant/components/frontend/www_static/favicon-1024x1024.png differ diff --git a/homeassistant/components/frontend/www_static/favicon-512x512.png b/homeassistant/components/frontend/www_static/favicon-512x512.png new file mode 100644 index 0000000000000..28239a05ad57c Binary files /dev/null and b/homeassistant/components/frontend/www_static/favicon-512x512.png differ diff --git a/homeassistant/components/frontend/www_static/frontend.html.gz b/homeassistant/components/frontend/www_static/frontend.html.gz index 6bfcf465ad070..58501c46c3081 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 0e6dc25fcd09a..168706fdb1922 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 0e6dc25fcd09ad1150aab258f8d01491a8ee8db7 +Subproject commit 168706fdb192219d8074d6709c0ce686180d1c8a diff --git a/homeassistant/components/frontend/www_static/manifest.json b/homeassistant/components/frontend/www_static/manifest.json index 957c5812cd2e8..aa09fb0e03700 100644 --- a/homeassistant/components/frontend/www_static/manifest.json +++ b/homeassistant/components/frontend/www_static/manifest.json @@ -4,6 +4,7 @@ "start_url": "/", "display": "standalone", "theme_color": "#03A9F4", + "background_color": "#FFFFFF", "icons": [ { "src": "/static/favicon-192x192.png", @@ -14,6 +15,16 @@ "src": "/static/favicon-384x384.png", "sizes": "384x384", "type": "image/png" + }, + { + "src": "/static/favicon-512x512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/static/favicon-1024x1024.png", + "sizes": "1024x1024", + "type": "image/png" } ] } diff --git a/homeassistant/components/frontend/www_static/service_worker.js.gz b/homeassistant/components/frontend/www_static/service_worker.js.gz index 6b15f7f6b4286..21b37fba8678f 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/garage_door/__init__.py b/homeassistant/components/garage_door/__init__.py index 30d9a5e2e0b7f..37e422d0f1209 100644 --- a/homeassistant/components/garage_door/__init__.py +++ b/homeassistant/components/garage_door/__init__.py @@ -17,7 +17,7 @@ from homeassistant.const import ( STATE_CLOSED, STATE_OPEN, STATE_UNKNOWN, SERVICE_CLOSE, SERVICE_OPEN, ATTR_ENTITY_ID) -from homeassistant.components import (group, wink) +from homeassistant.components import group DOMAIN = 'garage_door' SCAN_INTERVAL = 30 @@ -27,11 +27,6 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' -# Maps discovered services to their platforms -DISCOVERY_PLATFORMS = { - wink.DISCOVER_GARAGE_DOORS: 'wink' -} - GARAGE_DOOR_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, }) @@ -60,8 +55,7 @@ def open_door(hass, entity_id=None): def setup(hass, config): """Track states and offer events for garage door.""" component = EntityComponent( - _LOGGER, DOMAIN, hass, SCAN_INTERVAL, DISCOVERY_PLATFORMS, - GROUP_NAME_ALL_GARAGE_DOORS) + _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_GARAGE_DOORS) component.setup(config) def handle_garage_door_service(service): diff --git a/homeassistant/components/garage_door/wink.py b/homeassistant/components/garage_door/wink.py index ace97b5fba93f..18ec6f2ba565e 100644 --- a/homeassistant/components/garage_door/wink.py +++ b/homeassistant/components/garage_door/wink.py @@ -9,7 +9,7 @@ from homeassistant.components.garage_door import GarageDoorDevice from homeassistant.const import CONF_ACCESS_TOKEN, ATTR_BATTERY_LEVEL -REQUIREMENTS = ['python-wink==0.7.6'] +REQUIREMENTS = ['python-wink==0.7.7'] def setup_platform(hass, config, add_devices, discovery_info=None): diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py index c289cca42c578..6c63f2955f632 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -39,7 +39,7 @@ def _conf_preprocess(value): return value _SINGLE_GROUP_CONFIG = vol.Schema(vol.All(_conf_preprocess, { - vol.Optional(CONF_ENTITIES): vol.Any(None, cv.entity_ids), + vol.Optional(CONF_ENTITIES): vol.Any(cv.entity_ids, None), CONF_VIEW: bool, CONF_NAME: str, CONF_ICON: cv.icon, diff --git a/homeassistant/components/hvac/__init__.py b/homeassistant/components/hvac/__init__.py index ca5673aafb8da..85d10671a17ca 100644 --- a/homeassistant/components/hvac/__init__.py +++ b/homeassistant/components/hvac/__init__.py @@ -14,7 +14,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.temperature import convert from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa -from homeassistant.components import zwave from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_ON, STATE_OFF, STATE_UNKNOWN, TEMP_CELCIUS) @@ -57,10 +56,6 @@ _LOGGER = logging.getLogger(__name__) -DISCOVERY_PLATFORMS = { - zwave.DISCOVER_HVAC: 'zwave' -} - def set_away_mode(hass, away_mode, entity_id=None): """Turn all or specified hvac away mode on.""" @@ -139,8 +134,7 @@ def set_swing_mode(hass, swing_mode, entity_id=None): # pylint: disable=too-many-branches def setup(hass, config): """Setup hvacs.""" - component = EntityComponent(_LOGGER, DOMAIN, hass, - SCAN_INTERVAL, DISCOVERY_PLATFORMS) + component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) component.setup(config) descriptions = load_yaml_config_file( diff --git a/homeassistant/components/input_slider.py b/homeassistant/components/input_slider.py index a142fb364c70c..b667854e59c35 100644 --- a/homeassistant/components/input_slider.py +++ b/homeassistant/components/input_slider.py @@ -8,7 +8,7 @@ import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent @@ -68,18 +68,18 @@ def setup(hass, config): name = cfg.get(CONF_NAME) minimum = cfg.get(CONF_MIN) maximum = cfg.get(CONF_MAX) - state = cfg.get(CONF_INITIAL) - step = cfg.get(CONF_STEP) + state = cfg.get(CONF_INITIAL, minimum) + step = cfg.get(CONF_STEP, 1) icon = cfg.get(CONF_ICON) + unit = cfg.get(ATTR_UNIT_OF_MEASUREMENT) if state < minimum: state = minimum if state > maximum: state = maximum - entities.append( - InputSlider(object_id, name, state, minimum, maximum, step, icon) - ) + entities.append(InputSlider(object_id, name, state, minimum, maximum, + step, icon, unit)) if not entities: return False @@ -103,8 +103,9 @@ def select_value_service(call): class InputSlider(Entity): """Represent an slider.""" - # pylint: disable=too-many-arguments - def __init__(self, object_id, name, state, minimum, maximum, step, icon): + # pylint: disable=too-many-arguments, too-many-instance-attributes + def __init__(self, object_id, name, state, minimum, maximum, step, icon, + unit): """Initialize a select input.""" self.entity_id = ENTITY_ID_FORMAT.format(object_id) self._name = name @@ -113,6 +114,7 @@ def __init__(self, object_id, name, state, minimum, maximum, step, icon): self._maximum = maximum self._step = step self._icon = icon + self._unit = unit @property def should_poll(self): @@ -134,6 +136,11 @@ def state(self): """State of the component.""" return self._current_value + @property + def unit_of_measurement(self): + """Unit of measurement of slider.""" + return self._unit + @property def state_attributes(self): """State attributes.""" diff --git a/homeassistant/components/insteon_hub.py b/homeassistant/components/insteon_hub.py index a2688f48dd91b..5cfed006b8197 100644 --- a/homeassistant/components/insteon_hub.py +++ b/homeassistant/components/insteon_hub.py @@ -6,17 +6,12 @@ """ import logging -import homeassistant.bootstrap as bootstrap -from homeassistant.const import ( - ATTR_DISCOVERED, ATTR_SERVICE, CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME, - EVENT_PLATFORM_DISCOVERED) -from homeassistant.helpers import validate_config -from homeassistant.loader import get_component +from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import validate_config, discovery DOMAIN = "insteon_hub" REQUIREMENTS = ['insteon_hub==0.4.5'] INSTEON = None -DISCOVER_LIGHTS = "insteon_hub.lights" _LOGGER = logging.getLogger(__name__) @@ -44,11 +39,7 @@ def setup(hass, config): _LOGGER.error("Could not connect to Insteon service.") return - comp_name = 'light' - discovery = DISCOVER_LIGHTS - component = get_component(comp_name) - bootstrap.setup_component(hass, component.DOMAIN, config) - hass.bus.fire( - EVENT_PLATFORM_DISCOVERED, - {ATTR_SERVICE: discovery, ATTR_DISCOVERED: {}}) + for component in 'light': + discovery.load_platform(hass, component, DOMAIN, {}, config) + return True diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index 09bf62ce849b4..be964ebef7cd7 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -7,19 +7,15 @@ import logging from urllib.parse import urlparse -from homeassistant import bootstrap from homeassistant.const import ( - ATTR_DISCOVERED, ATTR_SERVICE, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, EVENT_PLATFORM_DISCOVERED) -from homeassistant.helpers import validate_config + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import validate_config, discovery from homeassistant.helpers.entity import ToggleEntity -from homeassistant.loader import get_component DOMAIN = "isy994" REQUIREMENTS = ['PyISY==1.0.6'] -DISCOVER_LIGHTS = "isy994.lights" -DISCOVER_SWITCHES = "isy994.switches" -DISCOVER_SENSORS = "isy994.sensors" + ISY = None SENSOR_STRING = 'Sensor' HIDDEN_STRING = '{HIDE ME}' @@ -76,15 +72,9 @@ def setup(hass, config): # Listen for HA stop to disconnect. hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop) - # Load components for the devices in the ISY controller that we support. - for comp_name, discovery in ((('sensor', DISCOVER_SENSORS), - ('light', DISCOVER_LIGHTS), - ('switch', DISCOVER_SWITCHES))): - component = get_component(comp_name) - bootstrap.setup_component(hass, component.DOMAIN, config) - hass.bus.fire(EVENT_PLATFORM_DISCOVERED, - {ATTR_SERVICE: discovery, - ATTR_DISCOVERED: {}}) + # Load platforms for the devices in the ISY controller that we support. + for component in ('sensor', 'light', 'switch'): + discovery.load_platform(hass, component, DOMAIN, {}, config) ISY.auto_update = True return True diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index d1fe0b93f4c05..27c68819909e0 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -10,9 +10,7 @@ import voluptuous as vol -from homeassistant.components import ( - group, discovery, wemo, wink, isy994, - zwave, insteon_hub, mysensors, tellstick, vera) +from homeassistant.components import group from homeassistant.config import load_yaml_config_file from homeassistant.const import ( STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, @@ -60,19 +58,6 @@ LIGHT_PROFILES_FILE = "light_profiles.csv" -# Maps discovered services to their platforms. -DISCOVERY_PLATFORMS = { - wemo.DISCOVER_LIGHTS: 'wemo', - wink.DISCOVER_LIGHTS: 'wink', - insteon_hub.DISCOVER_LIGHTS: 'insteon_hub', - isy994.DISCOVER_LIGHTS: 'isy994', - discovery.SERVICE_HUE: 'hue', - zwave.DISCOVER_LIGHTS: 'zwave', - mysensors.DISCOVER_LIGHTS: 'mysensors', - tellstick.DISCOVER_LIGHTS: 'tellstick', - vera.DISCOVER_LIGHTS: 'vera', -} - PROP_TO_ATTR = { 'brightness': ATTR_BRIGHTNESS, 'color_temp': ATTR_COLOR_TEMP, @@ -172,8 +157,7 @@ def toggle(hass, entity_id=None, transition=None): def setup(hass, config): """Expose light control via statemachine and services.""" component = EntityComponent( - _LOGGER, DOMAIN, hass, SCAN_INTERVAL, DISCOVERY_PLATFORMS, - GROUP_NAME_ALL_LIGHTS) + _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_LIGHTS) component.setup(config) # Load built-in profiles and custom profiles diff --git a/homeassistant/components/light/wink.py b/homeassistant/components/light/wink.py index c7a7637b04778..2438cdaab9abd 100644 --- a/homeassistant/components/light/wink.py +++ b/homeassistant/components/light/wink.py @@ -13,7 +13,7 @@ from homeassistant.util.color import \ color_temperature_mired_to_kelvin as mired_to_kelvin -REQUIREMENTS = ['python-wink==0.7.6'] +REQUIREMENTS = ['python-wink==0.7.7'] def setup_platform(hass, config, add_devices_callback, discovery_info=None): diff --git a/homeassistant/components/lirc.py b/homeassistant/components/lirc.py index ddb8b9542a530..70a14cf6130e6 100644 --- a/homeassistant/components/lirc.py +++ b/homeassistant/components/lirc.py @@ -65,6 +65,7 @@ def __init__(self, hass): def run(self): """Main loop of LIRC interface thread.""" import lirc + _LOGGER.debug('LIRC interface thread started') while not self.stopped.isSet(): try: code = lirc.nextcode() # list; empty if no buttons pressed @@ -80,4 +81,5 @@ def run(self): {BUTTON_NAME: code}) else: time.sleep(0.2) - _LOGGER.info('LIRC interface thread stopped') + lirc.deinit() + _LOGGER.debug('LIRC interface thread stopped') diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 8b27929e8161c..1986da20e94a6 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ATTR_CODE, ATTR_CODE_FORMAT, ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN, SERVICE_LOCK, SERVICE_UNLOCK) -from homeassistant.components import (group, verisure, wink, zwave) +from homeassistant.components import group DOMAIN = 'lock' SCAN_INTERVAL = 30 @@ -30,13 +30,6 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -# Maps discovered services to their platforms -DISCOVERY_PLATFORMS = { - wink.DISCOVER_LOCKS: 'wink', - verisure.DISCOVER_LOCKS: 'verisure', - zwave.DISCOVER_LOCKS: 'zwave', -} - LOCK_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(ATTR_CODE): cv.string, @@ -76,8 +69,7 @@ def unlock(hass, entity_id=None, code=None): def setup(hass, config): """Track states and offer events for locks.""" component = EntityComponent( - _LOGGER, DOMAIN, hass, SCAN_INTERVAL, DISCOVERY_PLATFORMS, - GROUP_NAME_ALL_LOCKS) + _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_LOCKS) component.setup(config) def handle_lock_service(service): diff --git a/homeassistant/components/lock/wink.py b/homeassistant/components/lock/wink.py index f8c0fd479b79e..2572796df35a7 100644 --- a/homeassistant/components/lock/wink.py +++ b/homeassistant/components/lock/wink.py @@ -9,7 +9,7 @@ from homeassistant.components.lock import LockDevice from homeassistant.const import CONF_ACCESS_TOKEN, ATTR_BATTERY_LEVEL -REQUIREMENTS = ['python-wink==0.7.6'] +REQUIREMENTS = ['python-wink==0.7.7'] def setup_platform(hass, config, add_devices, discovery_info=None): diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index c5e2eb00e57a0..25c55f46b7beb 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -9,7 +9,6 @@ import voluptuous as vol -from homeassistant.components import discovery from homeassistant.config import load_yaml_config_file from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent @@ -30,15 +29,6 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' -DISCOVERY_PLATFORMS = { - discovery.SERVICE_CAST: 'cast', - discovery.SERVICE_SONOS: 'sonos', - discovery.SERVICE_PLEX: 'plex', - discovery.SERVICE_SQUEEZEBOX: 'squeezebox', - discovery.SERVICE_PANASONIC_VIERA: 'panasonic_viera', - discovery.SERVICE_ROKU: 'roku', -} - SERVICE_PLAY_MEDIA = 'play_media' SERVICE_SELECT_SOURCE = 'select_source' @@ -285,8 +275,7 @@ def select_source(hass, source, entity_id=None): def setup(hass, config): """Track states and offer events for media_players.""" component = EntityComponent( - logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL, - DISCOVERY_PLATFORMS) + logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) component.setup(config) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 6c05984d9a466..351fb47a36812 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -11,7 +11,7 @@ MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - MediaPlayerDevice) + SUPPORT_STOP, MediaPlayerDevice) from homeassistant.const import ( CONF_HOST, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN) @@ -21,7 +21,7 @@ CAST_SPLASH = 'https://home-assistant.io/images/cast/splash.png' SUPPORT_CAST = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ - SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA + SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA | SUPPORT_STOP KNOWN_HOSTS = [] DEFAULT_PORT = 8009 @@ -241,6 +241,10 @@ def media_pause(self): """Send pause command.""" self.cast.media_controller.pause() + def media_stop(self): + """Send stop command.""" + self.cast.media_controller.stop() + def media_previous_track(self): """Send previous track command.""" self.cast.media_controller.rewind() diff --git a/homeassistant/components/media_player/pandora.py b/homeassistant/components/media_player/pandora.py new file mode 100644 index 0000000000000..8628a3125f8df --- /dev/null +++ b/homeassistant/components/media_player/pandora.py @@ -0,0 +1,368 @@ +""" +Component for controlling Pandora stations through the pianobar client. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/media_player.pandora/ +""" + +import logging +import re +import os +import signal +from datetime import timedelta +import shutil + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.components.media_player import ( + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, MEDIA_TYPE_MUSIC, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_SELECT_SOURCE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PLAY_PAUSE, + SERVICE_MEDIA_PLAY, SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN, + MediaPlayerDevice) +from homeassistant.const import (STATE_OFF, STATE_PAUSED, STATE_PLAYING, + STATE_IDLE) +from homeassistant import util + +REQUIREMENTS = ['pexpect==4.0.1'] +_LOGGER = logging.getLogger(__name__) + +# SUPPORT_VOLUME_SET is close to available but we need volume up/down +# controls in the GUI. +PANDORA_SUPPORT = \ + SUPPORT_PAUSE | \ + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_NEXT_TRACK | \ + SUPPORT_SELECT_SOURCE + +CMD_MAP = {SERVICE_MEDIA_NEXT_TRACK: 'n', + SERVICE_MEDIA_PLAY_PAUSE: 'p', + SERVICE_MEDIA_PLAY: 'p', + SERVICE_VOLUME_UP: ')', + SERVICE_VOLUME_DOWN: '('} +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=2) +CURRENT_SONG_PATTERN = re.compile(r'"(.*?)"\s+by\s+"(.*?)"\son\s+"(.*?)"', + re.MULTILINE) +STATION_PATTERN = re.compile(r'Station\s"(.+?)"', re.MULTILINE) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the media player pandora platform.""" + if not _pianobar_exists(): + return False + pandora = PandoraMediaPlayer('Pandora') + + # make sure we end the pandora subprocess on exit in case user doesn't + # power it down. + def _stop_pianobar(_event): + pandora.turn_off() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_pianobar) + add_devices([pandora]) + + +# pylint: disable=too-many-instance-attributes +class PandoraMediaPlayer(MediaPlayerDevice): + """A media player that uses the Pianobar interface to Pandora.""" + + # pylint: disable=abstract-method + def __init__(self, name): + """Initialize the demo device.""" + MediaPlayerDevice.__init__(self) + self._name = name + self._player_state = STATE_OFF + self._station = '' + self._media_title = '' + self._media_artist = '' + self._media_album = '' + self._stations = [] + self._time_remaining = 0 + self._media_duration = 0 + self._pianobar = None + + @property + def should_poll(self): + """Should be polled for current state.""" + return True + + @property + def name(self): + """Return the name of the media player.""" + return self._name + + @property + def state(self): + """Return the state of the player.""" + return self._player_state + + def turn_on(self): + """Turn the media player on.""" + import pexpect + if self._player_state != STATE_OFF: + return + self._pianobar = pexpect.spawn('pianobar') + _LOGGER.info('Started pianobar subprocess') + mode = self._pianobar.expect(['Receiving new playlist', + 'Select station:', + 'Email:']) + if mode == 1: + # station list was presented. dismiss it. + self._pianobar.sendcontrol('m') + elif mode == 2: + _LOGGER.warning('The pianobar client is not configured to log in. ' + 'Please create a config file for it as described ' + 'at https://home-assistant.io' + '/components/media_player.pandora/') + # pass through the email/password prompts to quit cleanly + self._pianobar.sendcontrol('m') + self._pianobar.sendcontrol('m') + self._pianobar.terminate() + self._pianobar = None + return + self._update_stations() + self.update_playing_status() + + self._player_state = STATE_IDLE + self.update_ha_state() + + def turn_off(self): + """Turn the media player off.""" + import pexpect + if self._pianobar is None: + _LOGGER.info('Pianobar subprocess already stopped') + return + self._pianobar.send('q') + try: + _LOGGER.info('Stopped Pianobar subprocess') + self._pianobar.terminate() + except pexpect.exceptions.TIMEOUT: + # kill the process group + os.killpg(os.getpgid(self._pianobar.pid), signal.SIGTERM) + _LOGGER.info('Killed Pianobar subprocess') + self._pianobar = None + self._player_state = STATE_OFF + self.update_ha_state() + + def media_play(self): + """Send play command.""" + self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE) + self._player_state = STATE_PLAYING + self.update_ha_state() + + def media_pause(self): + """Send pause command.""" + self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE) + self._player_state = STATE_PAUSED + self.update_ha_state() + + def media_next_track(self): + """Go to next track.""" + self._send_pianobar_command(SERVICE_MEDIA_NEXT_TRACK) + self.update_ha_state() + + @property + def supported_media_commands(self): + """Show what this supports.""" + return PANDORA_SUPPORT + + @property + def source(self): + """Name of the current input source.""" + return self._station + + @property + def source_list(self): + """List of available input sources.""" + return self._stations + + @property + def media_title(self): + """Title of current playing media.""" + self.update_playing_status() + return self._media_title + + @property + def media_content_type(self): + """Content type of current playing media.""" + return MEDIA_TYPE_MUSIC + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + return self._media_artist + + @property + def media_album_name(self): + """Album name of current playing media, music track only.""" + return self._media_album + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self._media_duration + + def select_source(self, source): + """Choose a different Pandora station and play it.""" + try: + station_index = self._stations.index(source) + except ValueError: + _LOGGER.warning('Station `%s` is not in list', source) + return + _LOGGER.info('Setting station %s, %d', source, station_index) + self._send_station_list_command() + self._pianobar.sendline('{}'.format(station_index)) + self._pianobar.expect('\r\n') + self._player_state = STATE_PLAYING + + def _send_station_list_command(self): + """Send a station list command.""" + import pexpect + self._pianobar.send('s') + try: + self._pianobar.expect('Select station:', timeout=1) + except pexpect.exceptions.TIMEOUT: + # try again. Buffer was contaminated. + self._clear_buffer() + self._pianobar.send('s') + self._pianobar.expect('Select station:') + + def update_playing_status(self): + """Query pianobar for info about current media_title, station.""" + response = self._query_for_playing_status() + if not response: + return + self._update_current_station(response) + self._update_current_song(response) + self._update_song_position() + + def _query_for_playing_status(self): + """Query system for info about current track.""" + import pexpect + self._clear_buffer() + self._pianobar.send('i') + try: + match_idx = self._pianobar.expect([br'(\d\d):(\d\d)/(\d\d):(\d\d)', + 'No song playing', + 'Select station', + 'Receiving new playlist']) + except pexpect.exceptions.EOF: + _LOGGER.info('Pianobar process already exited.') + return None + + self._log_match() + if match_idx == 1: + # idle. + response = None + elif match_idx == 2: + # stuck on a station selection dialog. Clear it. + _LOGGER.warning('On unexpected station list page.') + self._pianobar.sendcontrol('m') # press enter + self._pianobar.sendcontrol('m') # do it again b/c an 'i' got in + response = self.update_playing_status() + elif match_idx == 3: + _LOGGER.debug('Received new playlist list.') + response = self.update_playing_status() + else: + response = self._pianobar.before.decode('utf-8') + return response + + def _update_current_station(self, response): + """Update current station.""" + station_match = re.search(STATION_PATTERN, response) + if station_match: + self._station = station_match.group(1) + _LOGGER.debug('Got station as: %s', self._station) + else: + _LOGGER.warning('No station match. ') + + def _update_current_song(self, response): + """Update info about current song.""" + song_match = re.search(CURRENT_SONG_PATTERN, response) + if song_match: + (self._media_title, self._media_artist, + self._media_album) = song_match.groups() + _LOGGER.debug('Got song as: %s', self._media_title) + else: + _LOGGER.warning('No song match.') + + @util.Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_song_position(self): + """ + Get the song position and duration. + + It's hard to predict whether or not the music will start during init + so we have to detect state by checking the ticker. + + """ + (cur_minutes, cur_seconds, + total_minutes, total_seconds) = self._pianobar.match.groups() + time_remaining = int(cur_minutes) * 60 + int(cur_seconds) + self._media_duration = int(total_minutes) * 60 + int(total_seconds) + + if (time_remaining != self._time_remaining and + time_remaining != self._media_duration): + self._player_state = STATE_PLAYING + elif self._player_state == STATE_PLAYING: + self._player_state = STATE_PAUSED + self._time_remaining = time_remaining + + def _log_match(self): + """Log grabbed values from console.""" + _LOGGER.debug('Before: %s\nMatch: %s\nAfter: %s', + repr(self._pianobar.before), + repr(self._pianobar.match), + repr(self._pianobar.after)) + + def _send_pianobar_command(self, service_cmd): + """Send a command to Pianobar.""" + command = CMD_MAP.get(service_cmd) + _LOGGER.debug('Sending pinaobar command %s for %s', + command, service_cmd) + if command is None: + _LOGGER.info('Command %s not supported yet', service_cmd) + self._clear_buffer() + self._pianobar.sendline(command) + + def _update_stations(self): + """List defined Pandora stations.""" + self._send_station_list_command() + station_lines = self._pianobar.before.decode('utf-8') + _LOGGER.debug('Getting stations: %s', station_lines) + self._stations = [] + for line in station_lines.split('\r\n'): + match = re.search(r'\d+\).....(.+)', line) + if match: + station = match.group(1).strip() + _LOGGER.debug('Found station %s', station) + self._stations.append(station) + else: + _LOGGER.debug('No station match on `%s`', line) + self._pianobar.sendcontrol('m') # press enter with blank line + self._pianobar.sendcontrol('m') # do it twice in case an 'i' got in + + def _clear_buffer(self): + """ + Clear buffer from pexpect. + + This is necessary because there are a bunch of 00:00 in the buffer + + """ + import pexpect + try: + while not self._pianobar.expect('.+', timeout=0.1): + pass + except pexpect.exceptions.TIMEOUT: + pass + + +def _pianobar_exists(): + """Verify that Pianobar is properly installed.""" + pianobar_exe = shutil.which('pianobar') + if pianobar_exe: + return True + else: + _LOGGER.warning('The Pandora component depends on the Pianobar ' + 'client, which cannot be found. Please install ' + 'using instructions at' + 'https://home-assistant.io' + '/components/media_player.pandora/') + return False diff --git a/homeassistant/components/media_player/samsungtv.py b/homeassistant/components/media_player/samsungtv.py index 65a243e473aa9..61768b91f9652 100644 --- a/homeassistant/components/media_player/samsungtv.py +++ b/homeassistant/components/media_player/samsungtv.py @@ -72,7 +72,7 @@ def __init__(self, name, config): def update(self): """Retrieve the latest data.""" # Send an empty key to see if we are still connected - return self.send_key('KEY_POWER') + return self.send_key('KEY') def get_remote(self): """Create or return a remote control instance.""" diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 5f1f35f065f9c..d1ef92ee4d565 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -153,3 +153,19 @@ sonos_group_players: entity_id: description: Name(s) of entites that will coordinate the grouping. Platform dependent. example: 'media_player.living_room_sonos' + +sonos_snapshot: + description: Take a snapshot of the media player. + + fields: + entity_id: + description: Name(s) of entites that will coordinate the grouping. Platform dependent. + example: 'media_player.living_room_sonos' + +sonos_restore: + description: Restore a snapshot of the media player. + + fields: + entity_id: + description: Name(s) of entites that will coordinate the grouping. Platform dependent. + example: 'media_player.living_room_sonos' \ No newline at end of file diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 01e3f8d9efc65..9239f1edae8ed 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -34,6 +34,8 @@ SUPPORT_SEEK SERVICE_GROUP_PLAYERS = 'sonos_group_players' +SERVICE_SNAPSHOT = 'sonos_snapshot' +SERVICE_RESTORE = 'sonos_restore' # pylint: disable=unused-argument @@ -84,6 +86,34 @@ def group_players_service(service): device.group_players() device.update_ha_state(True) + def snapshot(service): + """Take a snapshot.""" + entity_id = service.data.get('entity_id') + + if entity_id: + _devices = [device for device in devices + if device.entity_id == entity_id] + else: + _devices = devices + + for device in _devices: + device.snapshot(service) + device.update_ha_state(True) + + def restore(service): + """Restore a snapshot.""" + entity_id = service.data.get('entity_id') + + if entity_id: + _devices = [device for device in devices + if device.entity_id == entity_id] + else: + _devices = devices + + for device in _devices: + device.restore(service) + device.update_ha_state(True) + descriptions = load_yaml_config_file( path.join(path.dirname(__file__), 'services.yaml')) @@ -91,6 +121,14 @@ def group_players_service(service): group_players_service, descriptions.get(SERVICE_GROUP_PLAYERS)) + hass.services.register(DOMAIN, SERVICE_SNAPSHOT, + snapshot, + descriptions.get(SERVICE_SNAPSHOT)) + + hass.services.register(DOMAIN, SERVICE_RESTORE, + restore, + descriptions.get(SERVICE_RESTORE)) + return True @@ -136,6 +174,8 @@ def __init__(self, hass, player): super(SonosDevice, self).__init__() self._player = player self.update() + from soco.snapshot import Snapshot + self.soco_snapshot = Snapshot(self._player) @property def should_poll(self): @@ -315,6 +355,16 @@ def group_players(self): """Group all players under this coordinator.""" self._player.partymode() + @only_if_coordinator + def snapshot(self, service): + """Snapshot the player.""" + self.soco_snapshot.snapshot() + + @only_if_coordinator + def restore(self, service): + """Restore snapshot for the player.""" + self.soco_snapshot.restore(True) + @property def available(self): """Return True if player is reachable, False otherwise.""" diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index 9d42316d38233..767c2b966a6b8 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -7,14 +7,11 @@ import logging import socket -import homeassistant.bootstrap as bootstrap -from homeassistant.const import (ATTR_BATTERY_LEVEL, ATTR_DISCOVERED, - ATTR_SERVICE, CONF_OPTIMISTIC, +from homeassistant.const import (ATTR_BATTERY_LEVEL, CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - EVENT_PLATFORM_DISCOVERED, STATE_OFF, - STATE_ON, TEMP_CELSIUS) -from homeassistant.helpers import validate_config + STATE_OFF, STATE_ON, TEMP_CELSIUS) +from homeassistant.helpers import validate_config, discovery CONF_GATEWAYS = 'gateways' CONF_DEVICE = 'device' @@ -40,19 +37,6 @@ GATEWAYS = None -DISCOVER_SENSORS = 'mysensors.sensors' -DISCOVER_SWITCHES = 'mysensors.switches' -DISCOVER_LIGHTS = 'mysensors.lights' -DISCOVER_BINARY_SENSORS = 'mysensors.binary_sensor' - -# Maps discovered services to their platforms -DISCOVERY_COMPONENTS = [ - ('sensor', DISCOVER_SENSORS), - ('switch', DISCOVER_SWITCHES), - ('light', DISCOVER_LIGHTS), - ('binary_sensor', DISCOVER_BINARY_SENSORS), -] - def setup(hass, config): # pylint: disable=too-many-locals """Setup the MySensors component.""" @@ -124,14 +108,8 @@ def gw_start(event): GATEWAYS[device] = setup_gateway( device, persistence_file, baud_rate, tcp_port) - for (component, discovery_service) in DISCOVERY_COMPONENTS: - # Ensure component is loaded - if not bootstrap.setup_component(hass, component, config): - return False - # Fire discovery event - hass.bus.fire(EVENT_PLATFORM_DISCOVERED, { - ATTR_SERVICE: discovery_service, - ATTR_DISCOVERED: {}}) + for component in 'sensor', 'switch', 'light', 'binary_sensor': + discovery.load_platform(hass, component, DOMAIN, {}, config) return True diff --git a/homeassistant/components/netatmo.py b/homeassistant/components/netatmo.py new file mode 100644 index 0000000000000..a808985ae0eed --- /dev/null +++ b/homeassistant/components/netatmo.py @@ -0,0 +1,56 @@ +""" +Support for the Netatmo devices (Weather Station and Welcome camera). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/netatmo/ +""" +import logging +from urllib.error import HTTPError +from homeassistant.const import ( + CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME) +from homeassistant.helpers import validate_config, discovery + +REQUIREMENTS = [ + 'https://github.com/jabesq/netatmo-api-python/archive/' + 'v0.5.0.zip#lnetatmo==0.5.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_SECRET_KEY = 'secret_key' + +DOMAIN = "netatmo" +NETATMO_AUTH = None + +_LOGGER = logging.getLogger(__name__) + + +def setup(hass, config): + """Setup the Netatmo devices.""" + if not validate_config(config, + {DOMAIN: [CONF_API_KEY, + CONF_USERNAME, + CONF_PASSWORD, + CONF_SECRET_KEY]}, + _LOGGER): + return None + + import lnetatmo + + global NETATMO_AUTH + try: + NETATMO_AUTH = lnetatmo.ClientAuth(config[DOMAIN][CONF_API_KEY], + config[DOMAIN][CONF_SECRET_KEY], + config[DOMAIN][CONF_USERNAME], + config[DOMAIN][CONF_PASSWORD], + "read_station read_camera " + "access_camera") + except HTTPError: + _LOGGER.error( + "Connection error " + "Please check your settings for NatAtmo API.") + return False + + for component in 'camera', 'sensor': + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True diff --git a/homeassistant/components/notify/telegram.py b/homeassistant/components/notify/telegram.py index 77bf597d446d2..9173964c2758e 100644 --- a/homeassistant/components/notify/telegram.py +++ b/homeassistant/components/notify/telegram.py @@ -14,7 +14,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['python-telegram-bot==4.2.0'] +REQUIREMENTS = ['python-telegram-bot==4.2.1'] def get_service(hass, config): diff --git a/homeassistant/components/octoprint.py b/homeassistant/components/octoprint.py index 76db48b5a078e..bd90e67d0dfcc 100644 --- a/homeassistant/components/octoprint.py +++ b/homeassistant/components/octoprint.py @@ -9,9 +9,8 @@ import time import requests -from homeassistant.components import discovery from homeassistant.const import CONF_API_KEY, CONF_HOST -from homeassistant.helpers import validate_config +from homeassistant.helpers import validate_config, discovery DOMAIN = "octoprint" OCTOPRINT = None diff --git a/homeassistant/components/rollershutter/__init__.py b/homeassistant/components/rollershutter/__init__.py index 98bee4198020a..c5fcb594f31b6 100644 --- a/homeassistant/components/rollershutter/__init__.py +++ b/homeassistant/components/rollershutter/__init__.py @@ -29,9 +29,6 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' -# Maps discovered services to their platforms -DISCOVERY_PLATFORMS = {} - _LOGGER = logging.getLogger(__name__) ATTR_CURRENT_POSITION = 'current_position' @@ -68,8 +65,7 @@ def stop(hass, entity_id=None): def setup(hass, config): """Track states and offer events for roller shutters.""" component = EntityComponent( - _LOGGER, DOMAIN, hass, SCAN_INTERVAL, DISCOVERY_PLATFORMS, - GROUP_NAME_ALL_ROLLERSHUTTERS) + _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_ROLLERSHUTTERS) component.setup(config) def handle_rollershutter_service(service): diff --git a/homeassistant/components/rollershutter/wink.py b/homeassistant/components/rollershutter/wink.py new file mode 100644 index 0000000000000..e01b2573ac64b --- /dev/null +++ b/homeassistant/components/rollershutter/wink.py @@ -0,0 +1,92 @@ +""" +Support for Wink Shades. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/rollershutter.wink/ +""" +import logging + +from homeassistant.components.rollershutter import RollershutterDevice +from homeassistant.const import CONF_ACCESS_TOKEN + +REQUIREMENTS = ['python-wink==0.7.7'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Wink rollershutter platform.""" + import pywink + + if discovery_info is None: + token = config.get(CONF_ACCESS_TOKEN) + + if token is None: + logging.getLogger(__name__).error( + "Missing wink access_token. " + "Get one at https://winkbearertoken.appspot.com/") + return + + pywink.set_bearer_token(token) + + add_devices(WinkRollershutterDevice(shade) for shade in + pywink.get_shades()) + + +class WinkRollershutterDevice(RollershutterDevice): + """Representation of a Wink rollershutter (shades).""" + + def __init__(self, wink): + """Initialize the rollershutter.""" + self.wink = wink + self._battery = None + + @property + def should_poll(self): + """Wink Shades don't track their position.""" + return False + + @property + def unique_id(self): + """Return the ID of this wink rollershutter.""" + return "{}.{}".format(self.__class__, self.wink.device_id()) + + @property + def name(self): + """Return the name of the rollershutter if any.""" + return self.wink.name() + + def update(self): + """Update the state of the rollershutter.""" + return self.wink.update_state() + + @property + def available(self): + """True if connection == True.""" + return self.wink.available + + def move_down(self): + """Close the shade.""" + self.wink.set_state(0) + + def move_up(self): + """Open the shade.""" + self.wink.set_state(1) + + @property + def current_position(self): + """Return current position of roller shutter. + + Wink reports blind shade positions as 0 or 1. + home-assistant expects: + None is unknown, 0 is closed, 100 is fully open. + """ + state = self.wink.state() + if state == 0: + return 0 + elif state == 1: + return 100 + else: + return None + + def stop(self): + """Can't stop Wink rollershutter due to API.""" + pass diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 3c625ae4a85fd..c018c04cdaf27 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -8,34 +8,17 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa -from homeassistant.components import ( - wink, zwave, isy994, verisure, ecobee, tellduslive, mysensors, - bloomsky, vera) DOMAIN = 'sensor' SCAN_INTERVAL = 30 ENTITY_ID_FORMAT = DOMAIN + '.{}' -# Maps discovered services to their platforms -DISCOVERY_PLATFORMS = { - bloomsky.DISCOVER_SENSORS: 'bloomsky', - wink.DISCOVER_SENSORS: 'wink', - zwave.DISCOVER_SENSORS: 'zwave', - isy994.DISCOVER_SENSORS: 'isy994', - verisure.DISCOVER_SENSORS: 'verisure', - ecobee.DISCOVER_SENSORS: 'ecobee', - tellduslive.DISCOVER_SENSORS: 'tellduslive', - mysensors.DISCOVER_SENSORS: 'mysensors', - vera.DISCOVER_SENSORS: 'vera', -} - def setup(hass, config): """Track states and offer events for sensors.""" component = EntityComponent( - logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL, - DISCOVERY_PLATFORMS) + logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) component.setup(config) diff --git a/homeassistant/components/sensor/bloomsky.py b/homeassistant/components/sensor/bloomsky.py index 790ed53dc341c..a9d2c0c6631c7 100644 --- a/homeassistant/components/sensor/bloomsky.py +++ b/homeassistant/components/sensor/bloomsky.py @@ -17,16 +17,18 @@ "Humidity", "Pressure", "Luminance", - "UVIndex"] + "UVIndex", + "Voltage"] # Sensor units - these do not currently align with the API documentation SENSOR_UNITS = {"Temperature": TEMP_FAHRENHEIT, "Humidity": "%", "Pressure": "inHg", - "Luminance": "cd/m²"} + "Luminance": "cd/m²", + "Voltage": "mV"} # Which sensors to format numerically -FORMAT_NUMBERS = ["Temperature", "Pressure"] +FORMAT_NUMBERS = ["Temperature", "Pressure", "Voltage"] # pylint: disable=unused-argument diff --git a/homeassistant/components/sensor/forecast.py b/homeassistant/components/sensor/forecast.py index c034c85fff4b1..cca1b7d52d78c 100644 --- a/homeassistant/components/sensor/forecast.py +++ b/homeassistant/components/sensor/forecast.py @@ -6,8 +6,12 @@ """ import logging from datetime import timedelta +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.helpers import validate_config from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -48,21 +52,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Forecast.io sensor.""" - import forecastio - + # Validate the configuration if None in (hass.config.latitude, hass.config.longitude): _LOGGER.error("Latitude or longitude not set in Home Assistant config") return False - - try: - forecast = forecastio.load_forecast(config.get(CONF_API_KEY, None), - hass.config.latitude, - hass.config.longitude) - forecast.currently() - except ValueError: - _LOGGER.error( - "Connection error " - "Please check your settings for Forecast.io.") + elif not validate_config({DOMAIN: config}, + {DOMAIN: [CONF_API_KEY]}, _LOGGER): return False if 'units' in config: @@ -72,43 +67,41 @@ def setup_platform(hass, config, add_devices, discovery_info=None): else: units = 'us' - data = ForeCastData(config.get(CONF_API_KEY, None), - hass.config.latitude, - hass.config.longitude, - units) + # Create a data fetcher to support all of the configured sensors. Then make + # the first call to init the data and confirm we can connect. + try: + forecast_data = ForeCastData( + config.get(CONF_API_KEY, None), hass.config.latitude, + hass.config.longitude, units) + forecast_data.update_currently() + except ValueError as error: + _LOGGER.error(error) + return False - dev = [] + # Initialize and add all of the sensors. + sensors = [] for variable in config['monitored_conditions']: - if variable not in SENSOR_TYPES: - _LOGGER.error('Sensor type: "%s" does not exist', variable) + if variable in SENSOR_TYPES: + sensors.append(ForeCastSensor(forecast_data, variable)) else: - dev.append(ForeCastSensor(data, variable)) + _LOGGER.error('Sensor type: "%s" does not exist', variable) - add_devices(dev) + add_devices(sensors) # pylint: disable=too-few-public-methods class ForeCastSensor(Entity): """Implementation of a Forecast.io sensor.""" - def __init__(self, weather_data, sensor_type): + def __init__(self, forecast_data, sensor_type): """Initialize the sensor.""" self.client_name = 'Weather' self._name = SENSOR_TYPES[sensor_type][0] - self.forecast_client = weather_data + self.forecast_data = forecast_data self.type = sensor_type self._state = None - self._unit_system = self.forecast_client.unit_system - if self._unit_system == 'si': - self._unit_of_measurement = SENSOR_TYPES[self.type][1] - elif self._unit_system == 'us': - self._unit_of_measurement = SENSOR_TYPES[self.type][2] - elif self._unit_system == 'ca': - self._unit_of_measurement = SENSOR_TYPES[self.type][3] - elif self._unit_system == 'uk': - self._unit_of_measurement = SENSOR_TYPES[self.type][4] - elif self._unit_system == 'uk2': - self._unit_of_measurement = SENSOR_TYPES[self.type][5] + self._unit_of_measurement = None + self.update() @property @@ -129,75 +122,72 @@ def unit_of_measurement(self): @property def unit_system(self): """Return the unit system of this entity.""" - return self._unit_system + return self.forecast_data.unit_system + + def update_unit_of_measurement(self): + """Update units based on unit system.""" + unit_index = { + 'si': 1, + 'us': 2, + 'ca': 3, + 'uk': 4, + 'uk2': 5 + }.get(self.unit_system, 1) + self._unit_of_measurement = SENSOR_TYPES[self.type][unit_index] - # pylint: disable=too-many-branches,too-many-statements def update(self): """Get the latest data from Forecast.io and updates the states.""" - import forecastio + # Call the API for new forecast data. Each sensor will re-trigger this + # same exact call, but thats fine. We cache results for a short period + # of time to prevent hitting API limits. Note that forecast.io will + # charge users for too many calls in 1 day, so take care when updating. + self.forecast_data.update() + self.update_unit_of_measurement() + + if self.type == 'minutely_summary': + self.forecast_data.update_minutely() + minutely = self.forecast_data.data_minutely + self._state = getattr(minutely, 'summary', '') + elif self.type == 'hourly_summary': + self.forecast_data.update_hourly() + hourly = self.forecast_data.data_hourly + self._state = getattr(hourly, 'summary', '') + elif self.type == 'daily_summary': + self.forecast_data.update_daily() + daily = self.forecast_data.data_daily + self._state = getattr(daily, 'summary', '') + else: + self.forecast_data.update_currently() + currently = self.forecast_data.data_currently + self._state = self.get_currently_state(currently) - self.forecast_client.update() + def get_currently_state(self, data): + """ + Helper function that returns a new state based on the type. - try: - if self.type == 'minutely_summary': - self.forecast_client.update_minutely() - self._state = self.forecast_client.data_minutely.summary - return + If the sensor type is unknown, the current state is returned. + """ + lookup_type = convert_to_camel(self.type) + state = getattr(data, lookup_type, 0) - elif self.type == 'hourly_summary': - self.forecast_client.update_hourly() - self._state = self.forecast_client.data_hourly.summary - return + # Some state data needs to be rounded to whole values or converted to + # percentages + if self.type in ['precip_probability', 'cloud_cover', 'humidity']: + return round(state * 100, 1) + elif (self.type in ['dew_point', 'temperature', 'apparent_temperature', + 'pressure', 'ozone']): + return round(state, 1) + return state - elif self.type == 'daily_summary': - self.forecast_client.update_daily() - self._state = self.forecast_client.data_daily.summary - return - except forecastio.utils.PropertyUnavailable: - return +def convert_to_camel(data): + """ + Convert snake case (foo_bar_bat) to camel case (fooBarBat). - self.forecast_client.update_currently() - data = self.forecast_client.data_currently - - try: - if self.type == 'summary': - self._state = data.summary - elif self.type == 'icon': - self._state = data.icon - elif self.type == 'nearest_storm_distance': - self._state = data.nearestStormDistance - elif self.type == 'nearest_storm_bearing': - self._state = data.nearestStormBearing - elif self.type == 'precip_intensity': - self._state = data.precipIntensity - elif self.type == 'precip_type': - self._state = data.precipType - elif self.type == 'precip_probability': - self._state = round(data.precipProbability * 100, 1) - elif self.type == 'dew_point': - self._state = round(data.dewPoint, 1) - elif self.type == 'temperature': - self._state = round(data.temperature, 1) - elif self.type == 'apparent_temperature': - self._state = round(data.apparentTemperature, 1) - elif self.type == 'wind_speed': - self._state = data.windSpeed - elif self.type == 'wind_bearing': - self._state = data.windBearing - elif self.type == 'cloud_cover': - self._state = round(data.cloudCover * 100, 1) - elif self.type == 'humidity': - self._state = round(data.humidity * 100, 1) - elif self.type == 'pressure': - self._state = round(data.pressure, 1) - elif self.type == 'visibility': - self._state = data.visibility - elif self.type == 'ozone': - self._state = round(data.ozone, 1) - - except forecastio.utils.PropertyUnavailable: - pass + This is not pythonic, but needed for certain situations + """ + components = data.split('_') + return components[0] + "".join(x.title() for x in components[1:]) class ForeCastData(object): @@ -226,10 +216,13 @@ def update(self): """Get the latest data from Forecast.io.""" import forecastio - self.data = forecastio.load_forecast(self._api_key, - self.latitude, - self.longitude, - units=self.units) + try: + self.data = forecastio.load_forecast(self._api_key, + self.latitude, + self.longitude, + units=self.units) + except (ConnectError, HTTPError, Timeout, ValueError) as error: + raise ValueError("Unable to init Forecast.io. - %s", error) self.unit_system = self.data.json['flags']['units'] @Throttle(MIN_TIME_BETWEEN_UPDATES) diff --git a/homeassistant/components/sensor/mysensors.py b/homeassistant/components/sensor/mysensors.py index c1eaa91353557..c6b2e5aa86ce5 100644 --- a/homeassistant/components/sensor/mysensors.py +++ b/homeassistant/components/sensor/mysensors.py @@ -37,7 +37,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): pres.S_POWER: [set_req.V_WATT, set_req.V_KWH], pres.S_DISTANCE: [set_req.V_DISTANCE], pres.S_LIGHT_LEVEL: [set_req.V_LIGHT_LEVEL], - pres.S_IR: [set_req.V_IR_SEND, set_req.V_IR_RECEIVE], + pres.S_IR: [set_req.V_IR_RECEIVE], pres.S_WATER: [set_req.V_FLOW, set_req.V_VOLUME], pres.S_CUSTOM: [set_req.V_VAR1, set_req.V_VAR2, diff --git a/homeassistant/components/sensor/netatmo.py b/homeassistant/components/sensor/netatmo.py index 184f9fe18a2f6..22caab1d1fbce 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -6,18 +6,12 @@ """ import logging from datetime import timedelta - -from homeassistant.components.sensor import DOMAIN -from homeassistant.const import ( - CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS) -from homeassistant.helpers import validate_config +from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle +from homeassistant.loader import get_component -REQUIREMENTS = [ - 'https://github.com/HydrelioxGitHub/netatmo-api-python/archive/' - '43ff238a0122b0939a0dc4e8836b6782913fb6e2.zip' - '#lnetatmo==0.4.0'] +DEPENDENCIES = ["netatmo"] _LOGGER = logging.getLogger(__name__) @@ -32,7 +26,6 @@ 'sum_rain_24': ['sum_rain_24', 'mm', 'mdi:weather-rainy'], } -CONF_SECRET_KEY = 'secret_key' CONF_STATION = 'station' ATTR_MODULE = 'modules' @@ -43,29 +36,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the NetAtmo sensor.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_API_KEY, - CONF_USERNAME, - CONF_PASSWORD, - CONF_SECRET_KEY]}, - _LOGGER): - return None - - import lnetatmo - - authorization = lnetatmo.ClientAuth(config.get(CONF_API_KEY, None), - config.get(CONF_SECRET_KEY, None), - config.get(CONF_USERNAME, None), - config.get(CONF_PASSWORD, None)) - - if not authorization: - _LOGGER.error( - "Connection error " - "Please check your settings for NatAtmo API.") - return False - - data = NetAtmoData(authorization, config.get(CONF_STATION, None)) + """Setup the available Netatmo weather sensors.""" + netatmo = get_component('netatmo') + data = NetAtmoData(netatmo.NETATMO_AUTH, config.get(CONF_STATION, None)) dev = [] try: diff --git a/homeassistant/components/sensor/nzbget.py b/homeassistant/components/sensor/nzbget.py index 91ffc16362103..874005cebca1b 100644 --- a/homeassistant/components/sensor/nzbget.py +++ b/homeassistant/components/sensor/nzbget.py @@ -158,7 +158,7 @@ def update(self): return if "DownloadRate" in self.type and value > 0: - # Convert download rate from bytes/s to mb/s - self._state = value / 1024 / 1024 + # Convert download rate from Bytes/s to MBytes/s + self._state = round(value / 1024 / 1024, 2) else: self._state = value diff --git a/homeassistant/components/sensor/openweathermap.py b/homeassistant/components/sensor/openweathermap.py index ba581d291774e..37afea8d393da 100644 --- a/homeassistant/components/sensor/openweathermap.py +++ b/homeassistant/components/sensor/openweathermap.py @@ -10,8 +10,7 @@ import voluptuous as vol from homeassistant.const import (CONF_API_KEY, TEMP_CELSIUS, TEMP_FAHRENHEIT, - CONF_PLATFORM, CONF_LATITUDE, CONF_LONGITUDE, - CONF_MONITORED_CONDITIONS) + CONF_PLATFORM, CONF_MONITORED_CONDITIONS) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -34,8 +33,7 @@ vol.Required(CONF_API_KEY): vol.Coerce(str), vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): [vol.In(SENSOR_TYPES.keys())], - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude + vol.Optional('forecast', default=False): cv.boolean }) # Return cached results if last scan was less then this time ago. @@ -52,7 +50,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): SENSOR_TYPES['temperature'][1] = hass.config.temperature_unit unit = hass.config.temperature_unit - forecast = config.get('forecast', 0) + forecast = config.get('forecast') owm = OWM(config.get(CONF_API_KEY, None)) if not owm: @@ -73,7 +71,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): except KeyError: pass - if forecast == 1: + if forecast: SENSOR_TYPES['forecast'] = ['Forecast', None] dev.append(OpenWeatherMapSensor(data, 'forecast', unit)) diff --git a/homeassistant/components/sensor/plex.py b/homeassistant/components/sensor/plex.py new file mode 100644 index 0000000000000..e35cb54cab82b --- /dev/null +++ b/homeassistant/components/sensor/plex.py @@ -0,0 +1,99 @@ +""" +Support for Plex media server monitoring. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.plex/ +""" +from datetime import timedelta +import logging +import voluptuous as vol + +from homeassistant.const import (CONF_NAME, CONF_PLATFORM, CONF_USERNAME, + CONF_PASSWORD, CONF_HOST, CONF_PORT) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['plexapi==1.1.0'] + +CONF_SERVER = 'server' +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'plex', + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SERVER): cv.string, + vol.Optional(CONF_NAME, default='Plex'): cv.string, + vol.Optional(CONF_HOST, default='localhost'): cv.string, + vol.Optional(CONF_PORT, default=32400): vol.All(vol.Coerce(int), + vol.Range(min=1, + max=65535)) +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Demo sensors.""" + name = config.get(CONF_NAME) + plex_user = config.get(CONF_USERNAME) + plex_password = config.get(CONF_PASSWORD) + plex_server = config.get(CONF_SERVER) + plex_host = config.get(CONF_HOST) + plex_port = config.get(CONF_PORT) + plex_url = 'http://' + plex_host + ':' + str(plex_port) + add_devices([PlexSensor(name, plex_url, plex_user, + plex_password, plex_server)]) + + +class PlexSensor(Entity): + """Plex now playing sensor.""" + + # pylint: disable=too-many-arguments + def __init__(self, name, plex_url, plex_user, plex_password, plex_server): + """Initialize the sensor.""" + 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) + server = plex_server if plex_server else user.resources()[0].name + self._server = user.getResource(server).connect() + else: + from plexapi.server import PlexServer + self._server = PlexServer(plex_url) + + self.update() + + @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 this state is expressed in.""" + return "Watching" + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {content[0]: content[1] for content in self._now_playing} + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + 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] + self._state = len(sessions) + self._now_playing = now_playing diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py new file mode 100644 index 0000000000000..59730624a114e --- /dev/null +++ b/homeassistant/components/sensor/snmp.py @@ -0,0 +1,134 @@ +""" +Support for displaying collected data over SNMP. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.snmp/ +""" +import logging +from datetime import timedelta +import voluptuous as vol + +from homeassistant.helpers.entity import Entity +from homeassistant.const import (CONF_HOST, CONF_PLATFORM, CONF_NAME, + CONF_PORT, ATTR_UNIT_OF_MEASUREMENT) +from homeassistant.util import Throttle + +REQUIREMENTS = ['pysnmp==4.3.2'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "SNMP" +DEFAULT_COMMUNITY = "public" +DEFAULT_PORT = "161" +CONF_COMMUNITY = "community" +CONF_BASEOID = "baseoid" + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'snmp', + vol.Optional(CONF_NAME): vol.Coerce(str), + vol.Required(CONF_HOST): vol.Coerce(str), + vol.Optional(CONF_PORT): vol.Coerce(int), + vol.Optional(CONF_COMMUNITY): vol.Coerce(str), + vol.Required(CONF_BASEOID): vol.Coerce(str), + vol.Optional(ATTR_UNIT_OF_MEASUREMENT): vol.Coerce(str), +}) + +# Return cached results if last scan was less then this time ago. +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) + + +# pylint: disable=too-many-locals +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the SNMP sensor.""" + from pysnmp.hlapi import (getCmd, CommunityData, SnmpEngine, + UdpTransportTarget, ContextData, ObjectType, + ObjectIdentity) + + host = config.get(CONF_HOST) + port = config.get(CONF_PORT, DEFAULT_PORT) + community = config.get(CONF_COMMUNITY, DEFAULT_COMMUNITY) + baseoid = config.get(CONF_BASEOID) + + errindication, _, _, _ = next( + getCmd(SnmpEngine(), + CommunityData(community, mpModel=0), + UdpTransportTarget((host, port)), + ContextData(), + ObjectType(ObjectIdentity(baseoid)))) + + if errindication: + _LOGGER.error('Please check the details in the configuration file') + return False + else: + data = SnmpData(host, port, community, baseoid) + add_devices([SnmpSensor(data, + config.get('name', DEFAULT_NAME), + config.get('unit_of_measurement'))]) + + +class SnmpSensor(Entity): + """Representation of a SNMP sensor.""" + + def __init__(self, data, name, unit_of_measurement): + """Initialize the sensor.""" + self.data = data + self._name = name + self._state = None + self._unit_of_measurement = unit_of_measurement + self.update() + + @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 the value is expressed in.""" + return self._unit_of_measurement + + def update(self): + """Get the latest data and updates the states.""" + self.data.update() + self._state = self.data.value + + +class SnmpData(object): + """Get the latest data and update the states.""" + + # pylint: disable=too-few-public-methods + def __init__(self, host, port, community, baseoid): + """Initialize the data object.""" + self._host = host + self._port = port + self._community = community + self._baseoid = baseoid + self.value = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from the remote SNMP capable host.""" + from pysnmp.hlapi import (getCmd, CommunityData, SnmpEngine, + UdpTransportTarget, ContextData, ObjectType, + ObjectIdentity) + errindication, errstatus, errindex, restable = next( + getCmd(SnmpEngine(), + CommunityData(self._community, mpModel=0), + UdpTransportTarget((self._host, self._port)), + ContextData(), + ObjectType(ObjectIdentity(self._baseoid))) + ) + + if errindication: + _LOGGER.error("SNMP error: %s", errindication) + elif errstatus: + _LOGGER.error('SNMP error: %s at %s', errstatus.prettyPrint(), + errindex and restable[-1][int(errindex) - 1] or '?') + else: + for resrow in restable: + self.value = resrow[-1] diff --git a/homeassistant/components/sensor/swiss_hydrological_data.py b/homeassistant/components/sensor/swiss_hydrological_data.py new file mode 100644 index 0000000000000..6bfda3f55f512 --- /dev/null +++ b/homeassistant/components/sensor/swiss_hydrological_data.py @@ -0,0 +1,163 @@ +""" +Support for hydrological data from the Federal Office for the Environment FOEN. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.swiss_hydrological_data/ +""" +import logging +import collections +from datetime import timedelta + +import voluptuous as vol +import requests + +from homeassistant.const import (TEMP_CELSIUS, CONF_PLATFORM, CONF_NAME) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['beautifulsoup4==4.4.1', 'lxml==3.6.0'] + +_LOGGER = logging.getLogger(__name__) +_RESOURCE = 'http://www.hydrodaten.admin.ch/en/' + +DEFAULT_NAME = 'Water temperature' +CONF_STATION = 'station' +ICON = 'mdi:cup-water' + +ATTR_DISCHARGE = 'Discharge' +ATTR_WATERLEVEL = 'Level' +ATTR_DISCHARGE_MEAN = 'Discharge mean' +ATTR_WATERLEVEL_MEAN = 'Level mean' +ATTR_TEMPERATURE_MEAN = 'Temperature mean' +ATTR_DISCHARGE_MAX = 'Discharge max' +ATTR_WATERLEVEL_MAX = 'Level max' +ATTR_TEMPERATURE_MAX = 'Temperature max' + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'swiss_hydrological_data', + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_STATION): cv.string, +}) + +HydroData = collections.namedtuple( + "HydrologicalData", + ['discharge', 'waterlevel', 'temperature', 'discharge_mean', + 'waterlevel_mean', 'temperature_mean', 'discharge_max', 'waterlevel_max', + 'temperature_max']) + +# Return cached results if last scan was less then this time ago. +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Swiss hydrological sensor.""" + station = config.get(CONF_STATION) + name = config.get(CONF_NAME, DEFAULT_NAME) + + try: + response = requests.get('{}/{}.html'.format(_RESOURCE, station), + timeout=5) + if not response.ok: + _LOGGER.error('The given station does not seem to exist: %s', + station) + return False + except requests.exceptions.ConnectionError: + _LOGGER.error('The URL is not accessible') + return False + + data = HydrologicalData(station) + add_devices([SwissHydrologicalDataSensor(name, data)]) + + +# pylint: disable=too-few-public-methods +class SwissHydrologicalDataSensor(Entity): + """Implementation of an Swiss hydrological sensor.""" + + def __init__(self, name, data): + """Initialize the sensor.""" + self.data = data + self._name = name + self._unit_of_measurement = TEMP_CELSIUS + self.update() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.data.measurings is not None: + return { + ATTR_DISCHARGE: self.data.measurings.discharge, + ATTR_WATERLEVEL: self.data.measurings.waterlevel, + ATTR_DISCHARGE_MEAN: self.data.measurings.discharge_mean, + ATTR_WATERLEVEL_MEAN: self.data.measurings.waterlevel_mean, + ATTR_TEMPERATURE_MEAN: self.data.measurings.temperature_mean, + ATTR_DISCHARGE_MAX: self.data.measurings.discharge_max, + ATTR_WATERLEVEL_MAX: self.data.measurings.waterlevel_max, + ATTR_TEMPERATURE_MAX: self.data.measurings.temperature_max, + } + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + # pylint: disable=too-many-branches + def update(self): + """Get the latest data and update the states.""" + self.data.update() + if self.data.measurings is not None: + self._state = self.data.measurings.temperature + + +# pylint: disable=too-few-public-methods +class HydrologicalData(object): + """The Class for handling the data retrieval.""" + + def __init__(self, station): + """Initialize the data object.""" + self.station = station + self.measurings = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from hydrodaten.admin.ch.""" + from bs4 import BeautifulSoup + + try: + response = requests.get('{}/{}.html'.format(_RESOURCE, + self.station), + timeout=5) + except requests.exceptions.ConnectionError: + _LOGGER.error('Unable to retrieve data') + response = None + + try: + tables = BeautifulSoup(response.content, + 'lxml').findChildren('table') + rows = tables[0].findChildren(['th', 'tr']) + + details = [] + + for row in rows: + cells = row.findChildren('td') + for cell in cells: + details.append(cell.string) + + self.measurings = HydroData._make(details) + except AttributeError: + self.measurings = None diff --git a/homeassistant/components/sensor/wink.py b/homeassistant/components/sensor/wink.py index c175901eaaae6..3fb914d6cd9e6 100644 --- a/homeassistant/components/sensor/wink.py +++ b/homeassistant/components/sensor/wink.py @@ -11,7 +11,7 @@ ATTR_BATTERY_LEVEL) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['python-wink==0.7.6'] +REQUIREMENTS = ['python-wink==0.7.7'] SENSOR_TYPES = ['temperature', 'humidity'] diff --git a/homeassistant/components/shell_command.py b/homeassistant/components/shell_command.py index 88df938d38c71..dec518db6ea8b 100644 --- a/homeassistant/components/shell_command.py +++ b/homeassistant/components/shell_command.py @@ -9,6 +9,8 @@ import voluptuous as vol +from homeassistant.helpers import template +from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv DOMAIN = 'shell_command' @@ -30,14 +32,38 @@ def setup(hass, config): def service_handler(call): """Execute a shell command service.""" + cmd = conf[call.service] + cmd, shell = _parse_command(hass, cmd, call.data) + if cmd is None: + return try: - subprocess.call(conf[call.service], shell=True, + subprocess.call(cmd, shell=shell, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except subprocess.SubprocessError: - _LOGGER.exception('Error running command') + _LOGGER.exception('Error running command: %s', cmd) for name in conf.keys(): hass.services.register(DOMAIN, name, service_handler, schema=SHELL_COMMAND_SCHEMA) return True + + +def _parse_command(hass, cmd, variables): + """Parse command and fill in any template arguments if necessary.""" + cmds = cmd.split() + prog = cmds[0] + args = ' '.join(cmds[1:]) + try: + rendered_args = template.render(hass, args, variables=variables) + except TemplateError as ex: + _LOGGER.exception('Error rendering command template: %s', ex) + return None, None + if rendered_args == args: + # no template used. default behavior + shell = True + else: + # template used. Must break into list and use shell=False for security + cmd = [prog] + rendered_args.split() + shell = False + return cmd, shell diff --git a/homeassistant/components/sun.py b/homeassistant/components/sun.py index 27cbbd9d2f886..791fec791f895 100644 --- a/homeassistant/components/sun.py +++ b/homeassistant/components/sun.py @@ -15,7 +15,7 @@ from homeassistant.util import location as location_util from homeassistant.const import CONF_ELEVATION -REQUIREMENTS = ['astral==1.1'] +REQUIREMENTS = ['astral==1.2'] DOMAIN = "sun" ENTITY_ID = "sun.sun" diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 8bd0585ff0c72..1f92b458d53e9 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -18,9 +18,7 @@ from homeassistant.const import ( STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, ATTR_ENTITY_ID) -from homeassistant.components import ( - group, wemo, wink, isy994, verisure, - zwave, tellduslive, tellstick, mysensors, vera) +from homeassistant.components import group DOMAIN = 'switch' SCAN_INTERVAL = 30 @@ -35,19 +33,6 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -# Maps discovered services to their platforms -DISCOVERY_PLATFORMS = { - wemo.DISCOVER_SWITCHES: 'wemo', - wink.DISCOVER_SWITCHES: 'wink', - isy994.DISCOVER_SWITCHES: 'isy994', - verisure.DISCOVER_SWITCHES: 'verisure', - zwave.DISCOVER_SWITCHES: 'zwave', - tellduslive.DISCOVER_SWITCHES: 'tellduslive', - mysensors.DISCOVER_SWITCHES: 'mysensors', - tellstick.DISCOVER_SWITCHES: 'tellstick', - vera.DISCOVER_SWITCHES: 'vera', -} - PROP_TO_ATTR = { 'current_power_mwh': ATTR_CURRENT_POWER_MWH, 'today_power_mw': ATTR_TODAY_MWH, @@ -87,8 +72,7 @@ def toggle(hass, entity_id=None): def setup(hass, config): """Track states and offer events for switches.""" component = EntityComponent( - _LOGGER, DOMAIN, hass, SCAN_INTERVAL, DISCOVERY_PLATFORMS, - GROUP_NAME_ALL_SWITCHES) + _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_SWITCHES) component.setup(config) def handle_switch_service(service): diff --git a/homeassistant/components/switch/mysensors.py b/homeassistant/components/switch/mysensors.py index 25cf4945d974b..102490286f6c4 100644 --- a/homeassistant/components/switch/mysensors.py +++ b/homeassistant/components/switch/mysensors.py @@ -5,14 +5,27 @@ https://home-assistant.io/components/switch.mysensors/ """ import logging +import os +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv from homeassistant.components import mysensors -from homeassistant.components.switch import SwitchDevice -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.components.switch import DOMAIN, SwitchDevice +from homeassistant.config import load_yaml_config_file +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON _LOGGER = logging.getLogger(__name__) DEPENDENCIES = [] +ATTR_IR_CODE = 'V_IR_SEND' +SERVICE_SEND_IR_CODE = 'mysensors_send_ir_code' + +SEND_IR_CODE_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_IR_CODE): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the mysensors platform for switches.""" @@ -32,6 +45,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): pres.S_SMOKE: [set_req.V_ARMED], pres.S_LIGHT: [set_req.V_LIGHT], pres.S_LOCK: [set_req.V_LOCK_STATUS], + pres.S_IR: [set_req.V_IR_SEND], + } + device_class_map = { + pres.S_DOOR: MySensorsSwitch, + pres.S_MOTION: MySensorsSwitch, + pres.S_SMOKE: MySensorsSwitch, + pres.S_LIGHT: MySensorsSwitch, + pres.S_LOCK: MySensorsSwitch, + pres.S_IR: MySensorsIRSwitch, } if float(gateway.version) >= 1.5: map_sv_types.update({ @@ -43,15 +65,53 @@ def setup_platform(hass, config, add_devices, discovery_info=None): pres.S_MOISTURE: [set_req.V_ARMED], }) map_sv_types[pres.S_LIGHT].append(set_req.V_STATUS) + device_class_map.update({ + pres.S_BINARY: MySensorsSwitch, + pres.S_SPRINKLER: MySensorsSwitch, + pres.S_WATER_LEAK: MySensorsSwitch, + pres.S_SOUND: MySensorsSwitch, + pres.S_VIBRATION: MySensorsSwitch, + pres.S_MOISTURE: MySensorsSwitch, + }) devices = {} gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, add_devices, MySensorsSwitch)) + map_sv_types, devices, add_devices, device_class_map)) + + def send_ir_code_service(service): + """Set IR code as device state attribute.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + ir_code = service.data.get(ATTR_IR_CODE) + + if entity_ids: + _devices = [device for device in devices.values() + if isinstance(device, MySensorsIRSwitch) and + device.entity_id in entity_ids] + else: + _devices = [device for device in devices.values() + if isinstance(device, MySensorsIRSwitch)] + + kwargs = {ATTR_IR_CODE: ir_code} + for device in _devices: + device.turn_on(**kwargs) + + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + hass.services.register(DOMAIN, SERVICE_SEND_IR_CODE, + send_ir_code_service, + descriptions.get(SERVICE_SEND_IR_CODE), + schema=SEND_IR_CODE_SERVICE_SCHEMA) class MySensorsSwitch(mysensors.MySensorsDeviceEntity, SwitchDevice): """Representation of the value of a MySensors Switch child node.""" + @property + def assumed_state(self): + """Return True if unable to access real state of entity.""" + return self.gateway.optimistic + @property def is_on(self): """Return True if switch is on.""" @@ -77,7 +137,60 @@ def turn_off(self): self._values[self.value_type] = STATE_OFF self.update_ha_state() + +class MySensorsIRSwitch(MySensorsSwitch): + """IR switch child class to MySensorsSwitch.""" + + def __init__(self, *args): + """Setup instance attributes.""" + MySensorsSwitch.__init__(self, *args) + self._ir_code = None + @property - def assumed_state(self): - """Return True if unable to access real state of entity.""" - return self.gateway.optimistic + def is_on(self): + """Return True if switch is on.""" + set_req = self.gateway.const.SetReq + if set_req.V_LIGHT in self._values: + return self._values[set_req.V_LIGHT] == STATE_ON + return False + + def turn_on(self, **kwargs): + """Turn the IR switch on.""" + set_req = self.gateway.const.SetReq + if set_req.V_LIGHT not in self._values: + _LOGGER.error('missing value_type: %s at node: %s, child: %s', + set_req.V_LIGHT.name, self.node_id, self.child_id) + return + if ATTR_IR_CODE in kwargs: + self._ir_code = kwargs[ATTR_IR_CODE] + self.gateway.set_child_value( + self.node_id, self.child_id, self.value_type, self._ir_code) + self.gateway.set_child_value( + self.node_id, self.child_id, set_req.V_LIGHT, 1) + if self.gateway.optimistic: + # optimistically assume that switch has changed state + self._values[self.value_type] = self._ir_code + self._values[set_req.V_LIGHT] = STATE_ON + self.update_ha_state() + # turn off switch after switch was turned on + self.turn_off() + + def turn_off(self): + """Turn the IR switch off.""" + set_req = self.gateway.const.SetReq + if set_req.V_LIGHT not in self._values: + _LOGGER.error('missing value_type: %s at node: %s, child: %s', + set_req.V_LIGHT.name, self.node_id, self.child_id) + return + self.gateway.set_child_value( + self.node_id, self.child_id, set_req.V_LIGHT, 0) + if self.gateway.optimistic: + # optimistically assume that switch has changed state + self._values[set_req.V_LIGHT] = STATE_OFF + self.update_ha_state() + + def update(self): + """Update the controller with the latest value from a sensor.""" + MySensorsSwitch.update(self) + if self.value_type in self._values: + self._ir_code = self._values[self.value_type] diff --git a/homeassistant/components/switch/netio.py b/homeassistant/components/switch/netio.py new file mode 100644 index 0000000000000..b33e71df49d3e --- /dev/null +++ b/homeassistant/components/switch/netio.py @@ -0,0 +1,226 @@ +""" +Netio switch component. + +The Netio platform allows you to control your [Netio] +(http://www.netio-products.com/en/overview/) Netio4, Netio4 All and Netio 230B. +These are smart outlets controllable through ethernet and/or WiFi that reports +consumptions (Netio4all). + +To use these devices in your installation, add the following to your +configuration.yaml file: +``` +switch: + - platform: netio + host: netio-living + outlets: + 1: "AppleTV" + 2: "Htpc" + 3: "Lampe Gauche" + 4: "Lampe Droite" + - platform: netio + host: 192.168.1.43 + port: 1234 + username: user + password: pwd + outlets: + 1: "Nothing..." + 4: "Lampe du fer" +``` + +To get pushed updates from the netio devices, one can add this lua code in the +device interface as an action triggered on "Netio" "System variables updated" +with an 'Always' schedule: + +`` +-- this will send socket and consumption status updates via CGI +-- to given address. Associate with 'System variables update' event +-- to get consumption updates when they show up + +local address='ha:8123' +local path = '/api/netio/' + + +local output = {} +for i = 1, 4 do for _, what in pairs({'state', 'consumption', + 'cumulatedConsumption', 'consumptionStart'}) do + local varname = string.format('output%d_%s', i, what) + table.insert(output, + varname..'='..tostring(devices.system[varname]):gsub(" ","|")) +end end + +local qs = table.concat(output, '&') +local url = string.format('http://%s%s?%s', address, path, qs) +devices.system.CustomCGI{url=url} +``` + + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.netio/ +""" + +import logging +from collections import namedtuple +from datetime import timedelta +from homeassistant import util +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_USERNAME, \ + CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP, STATE_ON +from homeassistant.helpers import validate_config +from homeassistant.components.switch import SwitchDevice + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['http'] +REQUIREMENTS = ['pynetio==0.1.6'] +DEFAULT_USERNAME = 'admin' +DEFAULT_PORT = 1234 +URL_API_NETIO_EP = "/api/netio/" + +CONF_OUTLETS = "outlets" +REQ_CONF = [CONF_HOST, CONF_OUTLETS] +ATTR_TODAY_MWH = "today_mwh" +ATTR_TOTAL_CONSUMPTION_KWH = "total_energy_kwh" +ATTR_CURRENT_POWER_MWH = "current_power_mwh" +ATTR_CURRENT_POWER_W = "current_power_w" + +Device = namedtuple('device', ['netio', 'entities']) +DEVICES = {} +ATTR_START_DATE = 'start_date' +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Configure the netio platform.""" + from pynetio import Netio + + if validate_config({"conf": config}, {"conf": [CONF_OUTLETS, + CONF_HOST]}, _LOGGER): + if len(DEVICES) == 0: + hass.wsgi.register_view(NetioApiView) + + dev = Netio(config[CONF_HOST], + config.get(CONF_PORT, DEFAULT_PORT), + config.get(CONF_USERNAME, DEFAULT_USERNAME), + config.get(CONF_PASSWORD, DEFAULT_USERNAME)) + + DEVICES[config[CONF_HOST]] = Device(dev, []) + + # Throttle the update for all NetioSwitches of one Netio + dev.update = util.Throttle(MIN_TIME_BETWEEN_SCANS)(dev.update) + + for key in config[CONF_OUTLETS]: + switch = NetioSwitch(DEVICES[config[CONF_HOST]].netio, key, + config[CONF_OUTLETS][key]) + DEVICES[config[CONF_HOST]].entities.append(switch) + + add_devices_callback(DEVICES[config[CONF_HOST]].entities) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, dispose) + return True + + +def dispose(event): + """Close connections to Netio Devices.""" + for _, value in DEVICES.items(): + value.netio.stop() + + +class NetioApiView(HomeAssistantView): + """WSGI handler class.""" + + url = URL_API_NETIO_EP + name = "api:netio" + + def get(self, request, host): + """Request handler.""" + data = request.args + states, consumptions, cumulated_consumptions, start_dates = \ + [], [], [], [] + + for i in range(1, 5): + out = 'output%d' % i + states.append(data.get('%s_state' % out) == STATE_ON) + consumptions.append(float(data.get('%s_consumption' % out, 0))) + cumulated_consumptions.append( + float(data.get('%s_cumulatedConsumption' % out, 0)) / 1000) + start_dates.append(data.get('%s_consumptionStart' % out, "")) + + _LOGGER.debug('%s: %s, %s, %s since %s', host, states, + consumptions, cumulated_consumptions, start_dates) + + ndev = DEVICES[host].netio + ndev.consumptions = consumptions + ndev.cumulated_consumptions = cumulated_consumptions + ndev.states = states + ndev.start_dates = start_dates + + for dev in DEVICES[host].entities: + dev.update_ha_state() + + return self.json(True) + + +class NetioSwitch(SwitchDevice): + """Provide a netio linked switch.""" + + def __init__(self, netio, outlet, name): + """Defined to handle throttle.""" + self._name = name + self.outlet = outlet + self.netio = netio + + @property + def name(self): + """Netio device's name.""" + return self._name + + @property + def available(self): + """Return True if entity is available.""" + return not hasattr(self, 'telnet') + + def turn_on(self): + """Turn switch on.""" + self._set(True) + + def turn_off(self): + """Turn switch off.""" + self._set(False) + + def _set(self, value): + val = list('uuuu') + val[self.outlet - 1] = "1" if value else "0" + self.netio.get('port list %s' % ''.join(val)) + self.netio.states[self.outlet - 1] = value + self.update_ha_state() + + @property + def is_on(self): + """Return switch's status.""" + return self.netio.states[self.outlet - 1] + + def update(self): + """Called by HA.""" + self.netio.update() + + @property + def state_attributes(self): + """Return optional state attributes.""" + return {ATTR_CURRENT_POWER_W: self.current_power_w, + ATTR_TOTAL_CONSUMPTION_KWH: self.cumulated_consumption_kwh, + ATTR_START_DATE: self.start_date.split('|')[0]} + + @property + def current_power_w(self): + """Return actual power.""" + return self.netio.consumptions[self.outlet - 1] + + @property + def cumulated_consumption_kwh(self): + """Total enerygy consumption since start_date.""" + return self.netio.cumulated_consumptions[self.outlet - 1] + + @property + def start_date(self): + """Point in time when the energy accumulation started.""" + return self.netio.start_dates[self.outlet - 1] diff --git a/homeassistant/components/switch/services.yaml b/homeassistant/components/switch/services.yaml index e69de29bb2d1d..00b2abb91a4dd 100644 --- a/homeassistant/components/switch/services.yaml +++ b/homeassistant/components/switch/services.yaml @@ -0,0 +1,37 @@ +# Describes the format for available switch services + +turn_on: + description: Turn a switch on + + fields: + entity_id: + description: Name(s) of entities to turn on + example: 'switch.living_room' + +turn_off: + description: Turn a switch off + + fields: + entity_id: + description: Name(s) of entities to turn off + example: 'switch.living_room' + +toggle: + description: Toggles a switch state + + fields: + entity_id: + description: Name(s) of entities to toggle + example: 'switch.living_room' + +mysensors_send_ir_code: + description: Set an IR code as a state attribute for a MySensors IR device switch and turn the switch on. + + fields: + entity_id: + description: Name(s) of entites that should have the IR code set and be turned on. Platform dependent. + example: 'switch.living_room_1_1' + + V_IR_SEND: + description: IR code to send + example: '0xC284' diff --git a/homeassistant/components/switch/wink.py b/homeassistant/components/switch/wink.py index d01c306db1c36..a5b67f5ddcf0b 100644 --- a/homeassistant/components/switch/wink.py +++ b/homeassistant/components/switch/wink.py @@ -9,7 +9,7 @@ from homeassistant.components.wink import WinkToggleDevice from homeassistant.const import CONF_ACCESS_TOKEN -REQUIREMENTS = ['python-wink==0.7.6'] +REQUIREMENTS = ['python-wink==0.7.7'] def setup_platform(hass, config, add_devices, discovery_info=None): diff --git a/homeassistant/components/tellduslive.py b/homeassistant/components/tellduslive.py index 7adbc2c7a247d..ebc6217462340 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive.py @@ -7,11 +7,7 @@ import logging from datetime import timedelta -from homeassistant import bootstrap -from homeassistant.const import ( - ATTR_DISCOVERED, ATTR_SERVICE, EVENT_PLATFORM_DISCOVERED) -from homeassistant.helpers import validate_config -from homeassistant.loader import get_component +from homeassistant.helpers import validate_config, discovery from homeassistant.util import Throttle DOMAIN = "tellduslive" @@ -20,12 +16,6 @@ _LOGGER = logging.getLogger(__name__) -DISCOVER_SENSORS = "tellduslive.sensors" -DISCOVER_SWITCHES = "tellduslive.switches" -DISCOVERY_TYPES = {"sensor": DISCOVER_SENSORS, - "switch": DISCOVER_SWITCHES} - - CONF_PUBLIC_KEY = "public_key" CONF_PRIVATE_KEY = "private_key" CONF_TOKEN = "token" @@ -101,16 +91,8 @@ def _discover(self, found_devices, component_name): _LOGGER.info("discovered %d new %s devices", len(found_devices), component_name) - component = get_component(component_name) - bootstrap.setup_component(self._hass, - component.DOMAIN, - self._config) - - discovery_type = DISCOVERY_TYPES[component_name] - - self._hass.bus.fire(EVENT_PLATFORM_DISCOVERED, - {ATTR_SERVICE: discovery_type, - ATTR_DISCOVERED: found_devices}) + discovery.load_platform(self._hass, component_name, DOMAIN, + found_devices, self._config) def request(self, what, **params): """Send a request to the Tellstick Live API.""" diff --git a/homeassistant/components/tellstick.py b/homeassistant/components/tellstick.py index 8bb9d6a53f0d3..0190f67982c94 100644 --- a/homeassistant/components/tellstick.py +++ b/homeassistant/components/tellstick.py @@ -8,11 +8,8 @@ import threading import voluptuous as vol -from homeassistant import bootstrap -from homeassistant.const import ( - ATTR_DISCOVERED, ATTR_SERVICE, - EVENT_PLATFORM_DISCOVERED, EVENT_HOMEASSISTANT_STOP) -from homeassistant.loader import get_component +from homeassistant.helpers import discovery +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.entity import Entity DOMAIN = "tellstick" @@ -24,11 +21,6 @@ ATTR_SIGNAL_REPETITIONS = "signal_repetitions" DEFAULT_SIGNAL_REPETITIONS = 1 -DISCOVER_SWITCHES = "tellstick.switches" -DISCOVER_LIGHTS = "tellstick.lights" -DISCOVERY_TYPES = {"switch": DISCOVER_SWITCHES, - "light": DISCOVER_LIGHTS} - ATTR_DISCOVER_DEVICES = "devices" ATTR_DISCOVER_CONFIG = "config" @@ -57,17 +49,11 @@ def _discover(hass, config, found_devices, component_name): _LOGGER.info("discovered %d new %s devices", len(found_devices), component_name) - component = get_component(component_name) - bootstrap.setup_component(hass, component.DOMAIN, - config) - signal_repetitions = config[DOMAIN].get(ATTR_SIGNAL_REPETITIONS) - hass.bus.fire(EVENT_PLATFORM_DISCOVERED, - {ATTR_SERVICE: DISCOVERY_TYPES[component_name], - ATTR_DISCOVERED: {ATTR_DISCOVER_DEVICES: found_devices, - ATTR_DISCOVER_CONFIG: - signal_repetitions}}) + discovery.load_platform(hass, component_name, DOMAIN, { + ATTR_DISCOVER_DEVICES: found_devices, + ATTR_DISCOVER_CONFIG: signal_repetitions}, config) def setup(hass, config): diff --git a/homeassistant/components/thermostat/__init__.py b/homeassistant/components/thermostat/__init__.py index 0004156aecf70..4c57a23ff9cb2 100644 --- a/homeassistant/components/thermostat/__init__.py +++ b/homeassistant/components/thermostat/__init__.py @@ -15,7 +15,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.temperature import convert from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa -from homeassistant.components import (ecobee, zwave) import homeassistant.helpers.config_validation as cv from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_ON, STATE_OFF, STATE_UNKNOWN, @@ -29,6 +28,7 @@ SERVICE_SET_AWAY_MODE = "set_away_mode" SERVICE_SET_TEMPERATURE = "set_temperature" SERVICE_SET_FAN_MODE = "set_fan_mode" +SERVICE_SET_HVAC_MODE = "set_hvac_mode" STATE_HEAT = "heat" STATE_COOL = "cool" @@ -37,6 +37,7 @@ ATTR_CURRENT_TEMPERATURE = "current_temperature" ATTR_AWAY_MODE = "away_mode" ATTR_FAN = "fan" +ATTR_HVAC_MODE = "hvac_mode" ATTR_MAX_TEMP = "max_temp" ATTR_MIN_TEMP = "min_temp" ATTR_TEMPERATURE_LOW = "target_temp_low" @@ -45,11 +46,6 @@ _LOGGER = logging.getLogger(__name__) -DISCOVERY_PLATFORMS = { - ecobee.DISCOVER_THERMOSTAT: 'ecobee', - zwave.DISCOVER_THERMOSTATS: 'zwave' -} - SET_AWAY_MODE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_AWAY_MODE): cv.boolean, @@ -62,6 +58,10 @@ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_FAN): cv.boolean, }) +SET_HVAC_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_HVAC_MODE): cv.string, +}) def set_away_mode(hass, away_mode, entity_id=None): @@ -98,11 +98,22 @@ def set_fan_mode(hass, fan_mode, entity_id=None): hass.services.call(DOMAIN, SERVICE_SET_FAN_MODE, data) +def set_hvac_mode(hass, hvac_mode, entity_id=None): + """Set specified thermostat hvac mode.""" + data = { + ATTR_HVAC_MODE: hvac_mode + } + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SET_HVAC_MODE, data) + + # pylint: disable=too-many-branches def setup(hass, config): """Setup thermostats.""" - component = EntityComponent(_LOGGER, DOMAIN, hass, - SCAN_INTERVAL, DISCOVERY_PLATFORMS) + component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) component.setup(config) descriptions = load_yaml_config_file( @@ -164,6 +175,22 @@ def fan_mode_set_service(service): descriptions.get(SERVICE_SET_FAN_MODE), schema=SET_FAN_MODE_SCHEMA) + def hvac_mode_set_service(service): + """Set hvac mode on target thermostats.""" + target_thermostats = component.extract_from_service(service) + + hvac_mode = service.data[ATTR_HVAC_MODE] + + for thermostat in target_thermostats: + thermostat.set_hvac_mode(hvac_mode) + + thermostat.update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_SET_HVAC_MODE, hvac_mode_set_service, + descriptions.get(SERVICE_SET_HVAC_MODE), + schema=SET_HVAC_MODE_SCHEMA) + return True @@ -250,6 +277,10 @@ def set_temperate(self, temperature): """Set new target temperature.""" pass + def set_hvac_mode(self, hvac_mode): + """Set hvac mode.""" + pass + def turn_away_mode_on(self): """Turn away mode on.""" pass diff --git a/homeassistant/components/thermostat/ecobee.py b/homeassistant/components/thermostat/ecobee.py index abeda6be73676..f07ef47269d1c 100644 --- a/homeassistant/components/thermostat/ecobee.py +++ b/homeassistant/components/thermostat/ecobee.py @@ -5,17 +5,29 @@ https://home-assistant.io/components/thermostat.ecobee/ """ import logging +from os import path +import voluptuous as vol from homeassistant.components import ecobee from homeassistant.components.thermostat import ( - STATE_COOL, STATE_HEAT, STATE_IDLE, ThermostatDevice) -from homeassistant.const import STATE_OFF, STATE_ON, TEMP_FAHRENHEIT + DOMAIN, STATE_COOL, STATE_HEAT, STATE_IDLE, ThermostatDevice) +from homeassistant.const import ( + ATTR_ENTITY_ID, STATE_OFF, STATE_ON, TEMP_FAHRENHEIT) +from homeassistant.config import load_yaml_config_file +import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['ecobee'] _LOGGER = logging.getLogger(__name__) ECOBEE_CONFIG_FILE = 'ecobee.conf' _CONFIGURING = {} +ATTR_FAN_MIN_ON_TIME = "fan_min_on_time" +SERVICE_SET_FAN_MIN_ON_TIME = "ecobee_set_fan_min_on_time" +SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_FAN_MIN_ON_TIME): vol.Coerce(int), +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Ecobee Thermostat Platform.""" @@ -26,10 +38,37 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.info( "Loading ecobee thermostat component with hold_temp set to %s", hold_temp) - add_devices(Thermostat(data, index, hold_temp) - for index in range(len(data.ecobee.thermostats))) + devices = [Thermostat(data, index, hold_temp) + for index in range(len(data.ecobee.thermostats))] + add_devices(devices) + + def fan_min_on_time_set_service(service): + """Set the minimum fan on time on the target thermostats.""" + entity_id = service.data.get('entity_id') + + if entity_id: + target_thermostats = [device for device in devices + if device.entity_id == entity_id] + else: + target_thermostats = devices + + fan_min_on_time = service.data[ATTR_FAN_MIN_ON_TIME] + + for thermostat in target_thermostats: + thermostat.set_fan_min_on_time(str(fan_min_on_time)) + thermostat.update_ha_state(True) + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml')) + + hass.services.register( + DOMAIN, SERVICE_SET_FAN_MIN_ON_TIME, fan_min_on_time_set_service, + descriptions.get(SERVICE_SET_FAN_MIN_ON_TIME), + schema=SET_FAN_MIN_ON_TIME_SCHEMA) + + +# pylint: disable=too-many-public-methods class Thermostat(ThermostatDevice): """A thermostat class for Ecobee.""" @@ -127,6 +166,11 @@ def hvac_mode(self): """Return current hvac mode ie. auto, auxHeatOnly, cool, heat, off.""" return self.thermostat['settings']['hvacMode'] + @property + def fan_min_on_time(self): + """Return current fan minimum on time.""" + return self.thermostat['settings']['fanMinOnTime'] + @property def device_state_attributes(self): """Return device specific state attributes.""" @@ -135,7 +179,8 @@ def device_state_attributes(self): "humidity": self.humidity, "fan": self.fan, "mode": self.mode, - "hvac_mode": self.hvac_mode + "hvac_mode": self.hvac_mode, + "fan_min_on_time": self.fan_min_on_time } @property @@ -177,6 +222,11 @@ def set_hvac_mode(self, mode): """Set HVAC mode (auto, auxHeatOnly, cool, heat, off).""" self.data.ecobee.set_hvac_mode(self.thermostat_index, mode) + def set_fan_min_on_time(self, fan_min_on_time): + """Set the minimum fan on time.""" + self.data.ecobee.set_fan_min_on_time(self.thermostat_index, + fan_min_on_time) + # Home and Sleep mode aren't used in UI yet: # def turn_home_mode_on(self): diff --git a/homeassistant/components/thermostat/services.yaml b/homeassistant/components/thermostat/services.yaml index 3592dfce75d36..9ce1ab704e6ab 100644 --- a/homeassistant/components/thermostat/services.yaml +++ b/homeassistant/components/thermostat/services.yaml @@ -34,3 +34,15 @@ set_fan_mode: fan: description: New value of fan mode example: true + +ecobee_set_fan_min_on_time: + description: Set the minimum time, in minutes, to run the fan each hour + + fields: + entity_id: + descriptions: Name(s) of entities to change + example: 'thermostat.ecobee' + + fan_min_on_time: + description: New value of fan minimum on time + example: 5 diff --git a/homeassistant/components/thermostat/zwave.py b/homeassistant/components/thermostat/zwave.py index a8632cde12834..4eb18664a24c3 100644 --- a/homeassistant/components/thermostat/zwave.py +++ b/homeassistant/components/thermostat/zwave.py @@ -25,6 +25,12 @@ REMOTEC_ZXT_120_THERMOSTAT: WORKAROUND_IGNORE } +COMMAND_CLASS_THERMOSTAT_FAN_STATE = 69 # 0x45 +COMMAND_CLASS_THERMOSTAT_SETPOINT = 67 # 0x43 +COMMAND_CLASS_SENSOR_MULTILEVEL = 49 # 0x31 +COMMAND_CLASS_THERMOSTAT_OPERATING_STATE = 66 # 0x42 +COMMAND_CLASS_THERMOSTAT_MODE = 64 # 0x40 + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the ZWave thermostats.""" @@ -51,7 +57,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): discovery_info, zwave.NETWORK) -# pylint: disable=too-many-arguments +# pylint: disable=too-many-arguments, too-many-instance-attributes class ZWaveThermostat(zwave.ZWaveDeviceEntity, ThermostatDevice): """Represents a HeatControl thermostat.""" @@ -61,11 +67,12 @@ def __init__(self, value): from pydispatch import dispatcher zwave.ZWaveDeviceEntity.__init__(self, value, DOMAIN) self._node = value.node - self._target_temperature = None + self._index = value.index self._current_temperature = None - self._current_operation = STATE_IDLE - self._current_operation_state = STATE_IDLE self._unit = None + self._current_operation_state = STATE_IDLE + self._target_temperature = None + self._current_fan_state = STATE_IDLE self.update_properties() # register listener dispatcher.connect( @@ -79,31 +86,37 @@ def value_changed(self, value): def update_properties(self): """Callback on data change for the registered node/value pair.""" - # set point - for _, value in self._node.get_values(class_id=0x43).items(): - if int(value.data) != 0: - self._target_temperature = int(value.data) - # Operation - for _, value in self._node.get_values(class_id=0x40).items(): - self._current_operation = value.data_as_string - # Current Temp - for _, value in self._node.get_values_for_command_class(0x31).items(): + # current Temp + for _, value in self._node.get_values_for_command_class( + COMMAND_CLASS_SENSOR_MULTILEVEL).items(): self._current_temperature = int(value.data) self._unit = value.units - # COMMAND_CLASS_THERMOSTAT_OPERATING_STATE - for _, value in self._node.get_values(class_id=0x42).items(): + + # operation state + for _, value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_OPERATING_STATE).items(): self._current_operation_state = value.data_as_string + # target temperature + temps = [] + for _, value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).items(): + temps.append(int(value.data)) + if value.index == self._index: + self._target_temperature = value.data + self._target_temperature_high = max(temps) + self._target_temperature_low = min(temps) + + # fan state + for _, value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_FAN_STATE).items(): + self._current_fan_state = value.data_as_string + @property def should_poll(self): """No polling on ZWave.""" return False - @property - def is_fan_on(self): - """Return if the fan is not idle.""" - return self._current_operation_state != 'Idle' - @property def unit_of_measurement(self): """Return the unit of measurement.""" @@ -114,7 +127,6 @@ def unit_of_measurement(self): return TEMP_FAHRENHEIT else: return unit - return self.hass.config.temperature_unit @property def current_temperature(self): @@ -123,17 +135,24 @@ def current_temperature(self): @property def operation(self): - """Return the operation mode.""" - return self._current_operation + """Return current operation ie. heat, cool, idle.""" + return self._current_operation_state @property def target_temperature(self): """Return the temperature we try to reach.""" return self._target_temperature + @property + def is_fan_on(self): + """Return true if the fan is on.""" + return not (self._current_fan_state == 'Idle' or + self._current_fan_state == STATE_IDLE) + def set_temperature(self, temperature): """Set new target temperature.""" # set point - for _, value in self._node.get_values_for_command_class(0x43).items(): - if int(value.data) != 0: + for _, value in self._node.get_values_for_command_class( + COMMAND_CLASS_THERMOSTAT_SETPOINT).items(): + if int(value.data) != 0 and value.index == self._index: value.data = temperature diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index 96b95f0bba07a..ee55ec858cc5e 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -5,16 +5,13 @@ https://home-assistant.io/components/vera/ """ import logging - from collections import defaultdict + from requests.exceptions import RequestException -from homeassistant import bootstrap -from homeassistant.const import ( - ATTR_SERVICE, ATTR_DISCOVERED, - EVENT_HOMEASSISTANT_STOP, EVENT_PLATFORM_DISCOVERED) +from homeassistant.helpers import discovery +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.entity import Entity -from homeassistant.loader import get_component REQUIREMENTS = ['pyvera==0.2.10'] @@ -27,28 +24,18 @@ CONF_EXCLUDE = 'exclude' CONF_LIGHTS = 'lights' -BINARY_SENSOR = 'binary_sensor' -SENSOR = 'sensor' -LIGHT = 'light' -SWITCH = 'switch' - DEVICE_CATEGORIES = { - 'Sensor': BINARY_SENSOR, - 'Temperature Sensor': SENSOR, - 'Light Sensor': SENSOR, - 'Humidity Sensor': SENSOR, - 'Dimmable Switch': LIGHT, - 'Switch': SWITCH, - 'Armable Sensor': SWITCH, - 'On/Off Switch': SWITCH, + 'Sensor': 'binary_sensor', + 'Temperature Sensor': 'sensor', + 'Light Sensor': 'sensor', + 'Humidity Sensor': 'sensor', + 'Dimmable Switch': 'light', + 'Switch': 'switch', + 'Armable Sensor': 'switch', + 'On/Off Switch': 'switch', # 'Window Covering': NOT SUPPORTED YET } -DISCOVER_BINARY_SENSORS = 'vera.binary_sensors' -DISCOVER_SENSORS = 'vera.sensors' -DISCOVER_LIGHTS = 'vera.lights' -DISCOVER_SWITCHES = 'vera.switchs' - VERA_DEVICES = defaultdict(list) @@ -100,19 +87,13 @@ def stop_subscription(event): dev_type = DEVICE_CATEGORIES.get(device.category) if dev_type is None: continue - if dev_type == SWITCH and device.device_id in lights_ids: - dev_type = LIGHT + if dev_type == 'switch' and device.device_id in lights_ids: + dev_type = 'light' VERA_DEVICES[dev_type].append(device) - for comp_name, discovery in (((BINARY_SENSOR, DISCOVER_BINARY_SENSORS), - (SENSOR, DISCOVER_SENSORS), - (LIGHT, DISCOVER_LIGHTS), - (SWITCH, DISCOVER_SWITCHES))): - component = get_component(comp_name) - bootstrap.setup_component(hass, component.DOMAIN, base_config) - hass.bus.fire(EVENT_PLATFORM_DISCOVERED, - {ATTR_SERVICE: discovery, - ATTR_DISCOVERED: {}}) + for component in 'binary_sensor', 'sensor', 'light', 'switch': + discovery.load_platform(hass, component, DOMAIN, {}, base_config) + return True diff --git a/homeassistant/components/verisure.py b/homeassistant/components/verisure.py index a445eaaa40531..601264c70f8f0 100644 --- a/homeassistant/components/verisure.py +++ b/homeassistant/components/verisure.py @@ -9,19 +9,11 @@ import time from datetime import timedelta -from homeassistant import bootstrap -from homeassistant.const import ( - ATTR_DISCOVERED, ATTR_SERVICE, CONF_PASSWORD, CONF_USERNAME, - EVENT_PLATFORM_DISCOVERED) -from homeassistant.helpers import validate_config -from homeassistant.loader import get_component +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import validate_config, discovery from homeassistant.util import Throttle DOMAIN = "verisure" -DISCOVER_SENSORS = 'verisure.sensors' -DISCOVER_SWITCHES = 'verisure.switches' -DISCOVER_ALARMS = 'verisure.alarm_control_panel' -DISCOVER_LOCKS = 'verisure.lock' REQUIREMENTS = ['vsure==0.8.1'] @@ -43,15 +35,8 @@ def setup(hass, config): if not HUB.login(): return False - for comp_name, discovery in ((('sensor', DISCOVER_SENSORS), - ('switch', DISCOVER_SWITCHES), - ('alarm_control_panel', DISCOVER_ALARMS), - ('lock', DISCOVER_LOCKS))): - component = get_component(comp_name) - bootstrap.setup_component(hass, component.DOMAIN, config) - hass.bus.fire(EVENT_PLATFORM_DISCOVERED, - {ATTR_SERVICE: discovery, - ATTR_DISCOVERED: {}}) + for component in ('sensor', 'switch', 'alarm_control_panel', 'lock'): + discovery.load_platform(hass, component, DOMAIN, {}, config) return True @@ -142,7 +127,7 @@ def update_component(self, get_function, status): except AttributeError: status[overview.deviceLabel] = overview except self._verisure.Error as ex: - _LOGGER.error('Caught connection error %s, tries to reconnect', ex) + _LOGGER.info('Caught connection error %s, tries to reconnect', ex) self.reconnect() def reconnect(self): diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py index 0c1433574bf21..1a32d4c361e2c 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -6,29 +6,22 @@ """ import logging -from homeassistant.components import discovery +from homeassistant.components.discovery import SERVICE_WEMO +from homeassistant.helpers import discovery from homeassistant.const import EVENT_HOMEASSISTANT_STOP REQUIREMENTS = ['pywemo==0.4.3'] DOMAIN = 'wemo' -DISCOVER_LIGHTS = 'wemo.light' -DISCOVER_BINARY_SENSORS = 'wemo.binary_sensor' -DISCOVER_SWITCHES = 'wemo.switch' -# Mapping from Wemo model_name to service. +# Mapping from Wemo model_name to component. WEMO_MODEL_DISPATCH = { - 'Bridge': DISCOVER_LIGHTS, - 'Insight': DISCOVER_SWITCHES, - 'Maker': DISCOVER_SWITCHES, - 'Sensor': DISCOVER_BINARY_SENSORS, - 'Socket': DISCOVER_SWITCHES, - 'LightSwitch': DISCOVER_SWITCHES -} -WEMO_SERVICE_DISPATCH = { - DISCOVER_LIGHTS: 'light', - DISCOVER_BINARY_SENSORS: 'binary_sensor', - DISCOVER_SWITCHES: 'switch', + 'Bridge': 'light', + 'Insight': 'switch', + 'Maker': 'switch', + 'Sensor': 'binary_sensor', + 'Socket': 'switch', + 'LightSwitch': 'switch' } SUBSCRIPTION_REGISTRY = None @@ -64,13 +57,12 @@ def discovery_dispatch(service, discovery_info): _LOGGER.debug('Discovered unique device %s', serial) KNOWN_DEVICES.append(serial) - service = WEMO_MODEL_DISPATCH.get(model_name) or DISCOVER_SWITCHES - component = WEMO_SERVICE_DISPATCH.get(service) + component = WEMO_MODEL_DISPATCH.get(model_name, 'switch') - discovery.discover(hass, service, discovery_info, - component, config) + discovery.load_platform(hass, component, DOMAIN, discovery_info, + config) - discovery.listen(hass, discovery.SERVICE_WEMO, discovery_dispatch) + discovery.listen(hass, SERVICE_WEMO, discovery_dispatch) _LOGGER.info("Scanning for WeMo devices.") devices = [(device.host, device) for device in pywemo.discover_devices()] @@ -92,5 +84,5 @@ def discovery_dispatch(service, discovery_info): discovery_info = (device.name, device.model_name, url, device.mac, device.serialnumber) - discovery.discover(hass, discovery.SERVICE_WEMO, discovery_info) + discovery.discover(hass, SERVICE_WEMO, discovery_info) return True diff --git a/homeassistant/components/wink.py b/homeassistant/components/wink.py index 74939bd47cb95..85bc7f46cefc5 100644 --- a/homeassistant/components/wink.py +++ b/homeassistant/components/wink.py @@ -6,23 +6,12 @@ """ import logging -from homeassistant import bootstrap -from homeassistant.const import ( - ATTR_DISCOVERED, ATTR_SERVICE, CONF_ACCESS_TOKEN, - EVENT_PLATFORM_DISCOVERED, ATTR_BATTERY_LEVEL) -from homeassistant.helpers import validate_config +from homeassistant.const import CONF_ACCESS_TOKEN, ATTR_BATTERY_LEVEL +from homeassistant.helpers import validate_config, discovery from homeassistant.helpers.entity import ToggleEntity -from homeassistant.loader import get_component DOMAIN = "wink" -REQUIREMENTS = ['python-wink==0.7.6'] - -DISCOVER_LIGHTS = "wink.lights" -DISCOVER_SWITCHES = "wink.switches" -DISCOVER_SENSORS = "wink.sensors" -DISCOVER_BINARY_SENSORS = "wink.binary_sensors" -DISCOVER_LOCKS = "wink.locks" -DISCOVER_GARAGE_DOORS = "wink.garage_doors" +REQUIREMENTS = ['python-wink==0.7.7'] def setup(hass, config): @@ -36,28 +25,18 @@ def setup(hass, config): pywink.set_bearer_token(config[DOMAIN][CONF_ACCESS_TOKEN]) # Load components for the devices in the Wink that we support - for component_name, func_exists, discovery_type in ( - ('light', pywink.get_bulbs, DISCOVER_LIGHTS), - ('switch', lambda: pywink.get_switches or - pywink.get_sirens or - pywink.get_powerstrip_outlets, DISCOVER_SWITCHES), - ('binary_sensor', pywink.get_sensors, DISCOVER_BINARY_SENSORS), - ('sensor', lambda: pywink.get_sensors or - pywink.get_eggtrays, DISCOVER_SENSORS), - ('lock', pywink.get_locks, DISCOVER_LOCKS), - ('garage_door', pywink.get_garage_doors, DISCOVER_GARAGE_DOORS)): + for component_name, func_exists in ( + ('light', pywink.get_bulbs), + ('switch', lambda: pywink.get_switches or pywink.get_sirens or + pywink.get_powerstrip_outlets), + ('binary_sensor', pywink.get_sensors), + ('sensor', lambda: pywink.get_sensors or pywink.get_eggtrays), + ('lock', pywink.get_locks), + ('rollershutter', pywink.get_shades), + ('garage_door', pywink.get_garage_doors)): if func_exists(): - component = get_component(component_name) - - # Ensure component is loaded - bootstrap.setup_component(hass, component.DOMAIN, config) - - # Fire discovery event - hass.bus.fire(EVENT_PLATFORM_DISCOVERED, { - ATTR_SERVICE: discovery_type, - ATTR_DISCOVERED: {} - }) + discovery.load_platform(hass, component_name, DOMAIN, {}, config) return True diff --git a/homeassistant/components/zwave.py b/homeassistant/components/zwave.py index 3f396e2ded41b..b2dd036074c17 100644 --- a/homeassistant/components/zwave.py +++ b/homeassistant/components/zwave.py @@ -9,11 +9,11 @@ import time from pprint import pprint -from homeassistant import bootstrap +from homeassistant.helpers import discovery from homeassistant.const import ( - ATTR_BATTERY_LEVEL, ATTR_DISCOVERED, ATTR_ENTITY_ID, ATTR_LOCATION, - ATTR_SERVICE, CONF_CUSTOMIZE, EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, EVENT_PLATFORM_DISCOVERED) + ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, ATTR_LOCATION, + CONF_CUSTOMIZE, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers.event import track_time_change from homeassistant.util import convert, slugify @@ -37,14 +37,6 @@ SERVICE_SOFT_RESET = "soft_reset" SERVICE_TEST_NETWORK = "test_network" -DISCOVER_SENSORS = "zwave.sensors" -DISCOVER_SWITCHES = "zwave.switch" -DISCOVER_LIGHTS = "zwave.light" -DISCOVER_BINARY_SENSORS = 'zwave.binary_sensor' -DISCOVER_THERMOSTATS = 'zwave.thermostat' -DISCOVER_HVAC = 'zwave.hvac' -DISCOVER_LOCKS = 'zwave.lock' - EVENT_SCENE_ACTIVATED = "zwave.scene_activated" COMMAND_CLASS_SWITCH_MULTILEVEL = 38 @@ -71,39 +63,32 @@ # value type). DISCOVERY_COMPONENTS = [ ('sensor', - DISCOVER_SENSORS, [COMMAND_CLASS_SENSOR_MULTILEVEL, COMMAND_CLASS_METER, COMMAND_CLASS_ALARM], TYPE_WHATEVER, GENRE_USER), ('light', - DISCOVER_LIGHTS, [COMMAND_CLASS_SWITCH_MULTILEVEL], TYPE_BYTE, GENRE_USER), ('switch', - DISCOVER_SWITCHES, [COMMAND_CLASS_SWITCH_BINARY], TYPE_BOOL, GENRE_USER), ('binary_sensor', - DISCOVER_BINARY_SENSORS, [COMMAND_CLASS_SENSOR_BINARY], TYPE_BOOL, GENRE_USER), ('thermostat', - DISCOVER_THERMOSTATS, [COMMAND_CLASS_THERMOSTAT_SETPOINT], TYPE_WHATEVER, GENRE_WHATEVER), ('hvac', - DISCOVER_HVAC, [COMMAND_CLASS_THERMOSTAT_FAN_MODE], TYPE_WHATEVER, GENRE_WHATEVER), ('lock', - DISCOVER_LOCKS, [COMMAND_CLASS_DOOR_LOCK], TYPE_BOOL, GENRE_USER), @@ -235,7 +220,6 @@ def log_all(signal, value=None): def value_added(node, value): """Called when a value is added to a node on the network.""" for (component, - discovery_service, command_ids, value_type, value_genre) in DISCOVERY_COMPONENTS: @@ -247,9 +231,6 @@ def value_added(node, value): if value_genre is not None and value_genre != value.genre: continue - # Ensure component is loaded - bootstrap.setup_component(hass, component, config) - # Configure node name = "{}.{}".format(component, _object_id(value)) @@ -261,14 +242,10 @@ def value_added(node, value): else: value.disable_poll() - # Fire discovery event - hass.bus.fire(EVENT_PLATFORM_DISCOVERED, { - ATTR_SERVICE: discovery_service, - ATTR_DISCOVERED: { - ATTR_NODE_ID: node.node_id, - ATTR_VALUE_ID: value.value_id, - } - }) + discovery.load_platform(hass, component, DOMAIN, { + ATTR_NODE_ID: node.node_id, + ATTR_VALUE_ID: value.value_id, + }, config) def scene_activated(node, scene_id): """Called when a scene is activated on any node in the network.""" diff --git a/homeassistant/const.py b/homeassistant/const.py index c129e2220c569..1b6ae128c18fc 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" -__version__ = "0.21.2" +__version__ = "0.22.0" REQUIRED_PYTHON_VER = (3, 4) PLATFORM_FORMAT = '{}.{}' diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 031ab5227dcea..65a9fe9ebd88a 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -73,12 +73,13 @@ def entity_id(value): value = string(value).lower() if valid_entity_id(value): return value - raise vol.Invalid('Entity ID {} does not match format .' - .format(value)) + raise vol.Invalid('Entity ID {} is an invalid entity id'.format(value)) def entity_ids(value): """Validate Entity IDs.""" + if value is None: + raise vol.Invalid('Entity IDs can not be None') if isinstance(value, str): value = [ent_id.strip() for ent_id in value.split(',')] diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py new file mode 100644 index 0000000000000..480c786d31f1d --- /dev/null +++ b/homeassistant/helpers/discovery.py @@ -0,0 +1,86 @@ +"""Helper methods to help with platform discovery.""" + +from homeassistant import bootstrap +from homeassistant.const import ( + ATTR_DISCOVERED, ATTR_SERVICE, EVENT_PLATFORM_DISCOVERED) + +EVENT_LOAD_PLATFORM = 'load_platform.{}' +ATTR_PLATFORM = 'platform' + + +def listen(hass, service, callback): + """Setup listener for discovery of specific service. + + Service can be a string or a list/tuple. + """ + if isinstance(service, str): + service = (service,) + else: + service = tuple(service) + + def discovery_event_listener(event): + """Listen for discovery events.""" + if ATTR_SERVICE in event.data and event.data[ATTR_SERVICE] in service: + callback(event.data[ATTR_SERVICE], event.data.get(ATTR_DISCOVERED)) + + hass.bus.listen(EVENT_PLATFORM_DISCOVERED, discovery_event_listener) + + +def discover(hass, service, discovered=None, component=None, hass_config=None): + """Fire discovery event. Can ensure a component is loaded.""" + if component is not None: + bootstrap.setup_component(hass, component, hass_config) + + data = { + ATTR_SERVICE: service + } + + if discovered is not None: + data[ATTR_DISCOVERED] = discovered + + hass.bus.fire(EVENT_PLATFORM_DISCOVERED, data) + + +def listen_platform(hass, component, callback): + """Register a platform loader listener.""" + service = EVENT_LOAD_PLATFORM.format(component) + + def discovery_platform_listener(event): + """Listen for platform discovery events.""" + if event.data.get(ATTR_SERVICE) != service: + return + + platform = event.data.get(ATTR_PLATFORM) + + if not platform: + return + + callback(platform, event.data.get(ATTR_DISCOVERED)) + + hass.bus.listen(EVENT_PLATFORM_DISCOVERED, discovery_platform_listener) + + +def load_platform(hass, component, platform, discovered=None, + hass_config=None): + """Load a component and platform dynamically. + + Target components will be loaded and an EVENT_PLATFORM_DISCOVERED will be + fired to load the platform. The event will contain: + { ATTR_SERVICE = LOAD_PLATFORM + '.' + <> + ATTR_PLATFORM = <> + ATTR_DISCOVERED = <> } + + Use `listen_platform` to register a callback for these events. + """ + if component is not None: + bootstrap.setup_component(hass, component, hass_config) + + data = { + ATTR_SERVICE: EVENT_LOAD_PLATFORM.format(component), + ATTR_PLATFORM: platform, + } + + if discovered is not None: + data[ATTR_DISCOVERED] = discovered + + hass.bus.fire(EVENT_PLATFORM_DISCOVERED, data) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 2a99b57da558c..898a445c78807 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -2,11 +2,11 @@ from threading import Lock from homeassistant.bootstrap import prepare_setup_platform -from homeassistant.components import discovery, group +from homeassistant.components import group from homeassistant.const import ( ATTR_ENTITY_ID, CONF_SCAN_INTERVAL, CONF_ENTITY_NAMESPACE, DEVICE_DEFAULT_NAME) -from homeassistant.helpers import config_per_platform +from homeassistant.helpers import config_per_platform, discovery from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.event import track_utc_time_change from homeassistant.helpers.service import extract_entity_ids @@ -20,8 +20,7 @@ class EntityComponent(object): # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-arguments def __init__(self, logger, domain, hass, - scan_interval=DEFAULT_SCAN_INTERVAL, - discovery_platforms=None, group_name=None): + scan_interval=DEFAULT_SCAN_INTERVAL, group_name=None): """Initialize an entity component.""" self.logger = logger self.hass = hass @@ -29,7 +28,6 @@ def __init__(self, logger, domain, hass, self.domain = domain self.entity_id_format = domain + '.{}' self.scan_interval = scan_interval - self.discovery_platforms = discovery_platforms self.group_name = group_name self.entities = {} @@ -54,23 +52,14 @@ def setup(self, config): for p_type, p_config in config_per_platform(config, self.domain): self._setup_platform(p_type, p_config) - if self.discovery_platforms: - # Discovery listener for all items in discovery_platforms array - # passed from a component's setup method (e.g. light/__init__.py) - discovery.listen( - self.hass, self.discovery_platforms.keys(), - lambda service, info: - self._setup_platform(self.discovery_platforms[service], {}, - info)) - # Generic discovery listener for loading platform dynamically # Refer to: homeassistant.components.discovery.load_platform() - def load_platform_callback(service, info): + def component_platform_discovered(platform, info): """Callback to load a platform.""" - platform = info.pop(discovery.LOAD_PLATFORM) - self._setup_platform(platform, {}, info if info else None) - discovery.listen(self.hass, discovery.LOAD_PLATFORM + '.' + - self.domain, load_platform_callback) + self._setup_platform(platform, {}, info) + + discovery.listen_platform(self.hass, self.domain, + component_platform_discovered) def extract_from_service(self, service): """Extract all known entities from a service call. diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index fe57c95b284a3..9bc6910c68588 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -17,8 +17,8 @@ def track_state_change(hass, entity_ids, action, from_state=None, Returns the listener that listens on the bus for EVENT_STATE_CHANGED. Pass the return value into hass.bus.remove_listener to remove it. """ - from_state = _process_match_param(from_state) - to_state = _process_match_param(to_state) + from_state = _process_state_match(from_state) + to_state = _process_state_match(to_state) # Ensure it is a lowercase list with entity ids we want to match on if entity_ids == MATCH_ALL: @@ -155,7 +155,7 @@ def time_change_listener(event): hass.bus.listen(EVENT_TIME_CHANGED, time_change_listener) return time_change_listener - pmp = _process_match_param + pmp = _process_time_match year, month, day = pmp(year), pmp(month), pmp(day) hour, minute, second = pmp(hour), pmp(minute), pmp(second) @@ -190,7 +190,17 @@ def track_time_change(hass, action, year=None, month=None, day=None, second, local=True) -def _process_match_param(parameter): +def _process_state_match(parameter): + """Wrap parameter in a tuple if it is not one and returns it.""" + if parameter is None or parameter == MATCH_ALL: + return MATCH_ALL + elif isinstance(parameter, str) or not hasattr(parameter, '__iter__'): + return (parameter,) + else: + return tuple(parameter) + + +def _process_time_match(parameter): """Wrap parameter in a tuple if it is not one and returns it.""" if parameter is None or parameter == MATCH_ALL: return MATCH_ALL diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index 078bbb27b20d0..9c6e797acd1b4 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -12,9 +12,13 @@ ATTR_MESSAGE, SERVICE_NOTIFY) from homeassistant.components.sun import ( STATE_ABOVE_HORIZON, STATE_BELOW_HORIZON) +from homeassistant.components.switch.mysensors import ( + ATTR_IR_CODE, SERVICE_SEND_IR_CODE) from homeassistant.components.thermostat import ( ATTR_AWAY_MODE, ATTR_FAN, SERVICE_SET_AWAY_MODE, SERVICE_SET_FAN_MODE, SERVICE_SET_TEMPERATURE) +from homeassistant.components.thermostat.ecobee import ( + ATTR_FAN_MIN_ON_TIME, SERVICE_SET_FAN_MIN_ON_TIME) from homeassistant.components.hvac import ( ATTR_HUMIDITY, ATTR_SWING_MODE, ATTR_OPERATION_MODE, ATTR_AUX_HEAT, SERVICE_SET_HUMIDITY, SERVICE_SET_SWING_MODE, @@ -46,12 +50,14 @@ SERVICE_NOTIFY: [ATTR_MESSAGE], SERVICE_SET_AWAY_MODE: [ATTR_AWAY_MODE], SERVICE_SET_FAN_MODE: [ATTR_FAN], + SERVICE_SET_FAN_MIN_ON_TIME: [ATTR_FAN_MIN_ON_TIME], SERVICE_SET_TEMPERATURE: [ATTR_TEMPERATURE], SERVICE_SET_HUMIDITY: [ATTR_HUMIDITY], SERVICE_SET_SWING_MODE: [ATTR_SWING_MODE], SERVICE_SET_OPERATION_MODE: [ATTR_OPERATION_MODE], SERVICE_SET_AUX_HEAT: [ATTR_AUX_HEAT], SERVICE_SELECT_SOURCE: [ATTR_INPUT_SOURCE], + SERVICE_SEND_IR_CODE: [ATTR_IR_CODE] } # Update this dict when new services are added to HA. diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 23412344a85f4..1f702a5019362 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -1,10 +1,35 @@ """Color util methods.""" +import logging import math # pylint: disable=unused-import -from webcolors import html5_parse_legacy_color as color_name_to_rgb # noqa + +_LOGGER = logging.getLogger(__name__) HASS_COLOR_MAX = 500 # mireds (inverted) HASS_COLOR_MIN = 154 +COLORS = { + 'white': (255, 255, 255), 'beige': (245, 245, 220), + 'tan': (210, 180, 140), 'gray': (128, 128, 128), + 'navy blue': (0, 0, 128), 'royal blue': (8, 76, 158), + 'blue': (0, 0, 255), 'azure': (0, 127, 255), 'aqua': (127, 255, 212), + 'teal': (0, 128, 128), 'green': (0, 255, 0), + 'forest green': (34, 139, 34), 'olive': (128, 128, 0), + 'chartreuse': (127, 255, 0), 'lime': (191, 255, 0), + 'golden': (255, 215, 0), 'red': (255, 0, 0), 'coral': (0, 63, 72), + 'hot pink': (252, 15, 192), 'fuchsia': (255, 119, 255), + 'lavender': (181, 126, 220), 'indigo': (75, 0, 130), + 'maroon': (128, 0, 0), 'crimson': (220, 20, 60)} + + +def color_name_to_rgb(color_name): + """Convert color name to RGB hex value.""" + hex_value = COLORS.get(color_name.lower()) + + if not hex_value: + _LOGGER.error('unknown color supplied %s default to white', color_name) + hex_value = COLORS['white'] + + return hex_value # Taken from: diff --git a/requirements_all.txt b/requirements_all.txt index d6cbe9b19529d..368ea27649cae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -5,7 +5,6 @@ pytz>=2016.4 pip>=7.0.0 jinja2>=2.8 voluptuous==0.8.9 -webcolors==1.5 eventlet==0.19.0 # homeassistant.components.isy994 @@ -30,7 +29,10 @@ Werkzeug==0.11.5 apcaccess==0.0.4 # homeassistant.components.sun -astral==1.1 +astral==1.2 + +# homeassistant.components.sensor.swiss_hydrological_data +beautifulsoup4==4.4.1 # homeassistant.components.light.blinksticklight blinkstick==1.1.7 @@ -95,9 +97,6 @@ hikvision==0.4 # homeassistant.components.sensor.dht # http://github.com/mala-zaba/Adafruit_Python_DHT/archive/4101340de8d2457dd194bca1e8d11cbfc237e919.zip#Adafruit_DHT==1.1.0 -# homeassistant.components.sensor.netatmo -https://github.com/HydrelioxGitHub/netatmo-api-python/archive/43ff238a0122b0939a0dc4e8836b6782913fb6e2.zip#lnetatmo==0.4.0 - # homeassistant.components.switch.dlink https://github.com/LinuxChristian/pyW215/archive/v0.1.1.zip#pyW215==0.1.1 @@ -124,6 +123,9 @@ https://github.com/danieljkemp/onkyo-eiscp/archive/python3.zip#onkyo-eiscp==0.9. # homeassistant.components.device_tracker.fritz # https://github.com/deisi/fritzconnection/archive/b5c14515e1c8e2652b06b6316a7f3913df942841.zip#fritzconnection==0.4.6 +# homeassistant.components.netatmo +https://github.com/jabesq/netatmo-api-python/archive/v0.5.0.zip#lnetatmo==0.5.0 + # homeassistant.components.sensor.sabnzbd https://github.com/jamespcole/home-assistant-nzb-clients/archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip#python-sabnzbd==0.1 @@ -131,7 +133,7 @@ https://github.com/jamespcole/home-assistant-nzb-clients/archive/616cad591540925 https://github.com/kellerza/pyqwikswitch/archive/v0.4.zip#pyqwikswitch==0.4 # homeassistant.components.ecobee -https://github.com/nkgilley/python-ecobee-api/archive/4a884bc146a93991b4210f868f3d6aecf0a181e6.zip#python-ecobee==0.0.5 +https://github.com/nkgilley/python-ecobee-api/archive/4856a704670c53afe1882178a89c209b5f98533d.zip#python-ecobee==0.0.6 # homeassistant.components.switch.edimax https://github.com/rkabadi/pyedimax/archive/365301ce3ff26129a7910c501ead09ea625f3700.zip#pyedimax==0.1 @@ -172,6 +174,9 @@ lightify==1.0.3 # homeassistant.components.light.limitlessled limitlessled==1.0.0 +# homeassistant.components.sensor.swiss_hydrological_data +lxml==3.6.0 + # homeassistant.components.notify.message_bird messagebird==1.2.0 @@ -196,12 +201,14 @@ panasonic_viera==0.2 # homeassistant.components.device_tracker.aruba # homeassistant.components.device_tracker.asuswrt +# homeassistant.components.media_player.pandora pexpect==4.0.1 # homeassistant.components.light.hue phue==0.8 # homeassistant.components.media_player.plex +# homeassistant.components.sensor.plex plexapi==1.1.0 # homeassistant.components.thermostat.proliphix @@ -252,6 +259,9 @@ pyloopenergy==0.0.13 # homeassistant.components.device_tracker.netgear pynetgear==0.3.3 +# homeassistant.components.switch.netio +pynetio==0.1.6 + # homeassistant.components.alarm_control_panel.nx584 # homeassistant.components.binary_sensor.nx584 pynx584==0.2 @@ -263,6 +273,7 @@ pyowm==2.3.1 pyserial<=3.0 # homeassistant.components.device_tracker.snmp +# homeassistant.components.sensor.snmp pysnmp==4.3.2 # homeassistant.components.sensor.forecast @@ -287,7 +298,7 @@ python-pushover==0.2 python-statsd==1.7.2 # homeassistant.components.notify.telegram -python-telegram-bot==4.2.0 +python-telegram-bot==4.2.1 # homeassistant.components.sensor.twitch python-twitch==1.2.0 @@ -297,9 +308,10 @@ python-twitch==1.2.0 # homeassistant.components.garage_door.wink # homeassistant.components.light.wink # homeassistant.components.lock.wink +# homeassistant.components.rollershutter.wink # homeassistant.components.sensor.wink # homeassistant.components.switch.wink -python-wink==0.7.6 +python-wink==0.7.7 # homeassistant.components.keyboard pyuserinput==0.1.9 diff --git a/requirements_test.txt b/requirements_test.txt index 5aba9dc540f6a..5ec8619b37f7a 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,5 +5,6 @@ pytest>=2.9.1 pytest-cov>=2.2.0 pytest-timeout>=1.0.0 pytest-capturelog>=0.7 -betamax==0.5.1 +betamax==0.7.0 pydocstyle>=1.0.0 +httpretty==0.8.14 diff --git a/setup.py b/setup.py index a1d069c821fc3..b574e15693145 100755 --- a/setup.py +++ b/setup.py @@ -17,7 +17,6 @@ 'pip>=7.0.0', 'jinja2>=2.8', 'voluptuous==0.8.9', - 'webcolors==1.5', 'eventlet==0.19.0', ] diff --git a/tests/cassettes/tests.components.sensor.test_yr.TestSensorYr.test_custom_setup.json b/tests/cassettes/tests.components.sensor.test_yr.TestSensorYr.test_custom_setup.json index 6bd1601260d34..c647c4ae01769 100644 --- a/tests/cassettes/tests.components.sensor.test_yr.TestSensorYr.test_custom_setup.json +++ b/tests/cassettes/tests.components.sensor.test_yr.TestSensorYr.test_custom_setup.json @@ -1 +1 @@ -{"http_interactions": [{"recorded_at": "2015-12-28T01:34:34", "request": {"method": "GET", "headers": {"Accept": ["*/*"], "Accept-Encoding": ["gzip, deflate"], "User-Agent": ["python-requests/2.9.1"], "Connection": ["keep-alive"]}, "body": {"string": "", "encoding": "utf-8"}, "uri": "http://api.yr.no/weatherapi/locationforecast/1.9/?lat=32.87336;lon=117.22743;msl=0"}, "response": {"headers": {"Content-Length": ["3598"], "X-forecast-models": ["proff,ecdet"], "Via": ["1.1 varnish"], "Content-Encoding": ["gzip"], "Date": ["Mon, 28 Dec 2015 01:34:34 GMT"], "X-Varnish": ["2670913442 2670013167"], "Expires": ["Mon, 28 Dec 2015 02:01:51 GMT"], "Server": ["Apache"], "Age": ["1574"], "Content-Type": ["text/xml; charset=utf-8"], "X-Backend-Host": ["snipe_loc"], "X-slicenumber": ["30"], "Accept-Ranges": ["bytes"], "Last-Modified": ["Mon, 28 Dec 2015 01:08:20 GMT"], "Vary": ["Accept-Encoding"], "Connection": ["keep-alive"]}, "url": "http://api.yr.no/weatherapi/locationforecast/1.9/?lat=32.87336;lon=117.22743;msl=0", "body": {"base64_string": "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", "encoding": "utf-8"}, "status": {"code": 200, "message": "OK"}}}], "recorded_with": "betamax/0.5.1"} \ No newline at end of file +{"http_interactions": [{"request": {"uri": "http://api.yr.no/weatherapi/locationforecast/1.9/?lat=32.87336;lon=117.22743;msl=0", "method": "GET", "headers": {"Accept": ["*/*"], "User-Agent": ["python-requests/2.9.1"], "Accept-Encoding": ["gzip, deflate"], "Connection": ["keep-alive"]}, "body": {"encoding": "utf-8", "string": ""}}, "recorded_at": "2016-06-09T04:02:23", "response": {"url": "http://api.yr.no/weatherapi/locationforecast/1.9/?lat=32.87336;lon=117.22743;msl=0", "headers": {"Location": ["http://api.met.no/weatherapi/locationforecast/1.9/?lat=32.87336;lon=117.22743;msl=0"], "Age": ["0"], "X-Varnish": ["4249781791"], "Via": ["1.1 varnish"], "Server": ["Varnish"], "Date": ["Thu, 09 Jun 2016 04:02:21 GMT"], "Connection": ["close"], "Accept-Ranges": ["bytes"]}, "body": {"encoding": null, "string": ""}, "status": {"message": "Moved permanently", "code": 301}}}, {"request": {"uri": "http://api.met.no/weatherapi/locationforecast/1.9/?lat=32.87336;lon=117.22743;msl=0", "method": "GET", "headers": {"Accept": ["*/*"], "User-Agent": ["python-requests/2.9.1"], "Accept-Encoding": ["gzip, deflate"], "Connection": ["keep-alive"]}, "body": {"encoding": "utf-8", "string": ""}}, "recorded_at": "2016-06-09T04:02:23", "response": {"url": "http://api.met.no/weatherapi/locationforecast/1.9/?lat=32.87336;lon=117.22743;msl=0", "headers": {"Content-Length": ["3637"], "X-Backend-Host": ["ravn_loc"], "X-Varnish": ["4249782320 4249780502"], "Via": ["1.1 varnish"], "Last-Modified": ["Thu, 09 Jun 2016 04:02:21 GMT"], "X-forecast-models": ["proff,ecdet"], "Accept-Ranges": ["bytes"], "Expires": ["Thu, 09 Jun 2016 05:01:15 GMT"], "Content-Encoding": ["gzip"], "Age": ["1"], "Content-Type": ["text/xml; charset=utf-8"], "Server": ["Apache"], "Vary": ["Accept-Encoding"], "Date": ["Thu, 09 Jun 2016 04:02:22 GMT"], "X-slicenumber": ["83"], "Connection": ["keep-alive"]}, "body": {"encoding": "utf-8", "base64_string": "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"}, "status": {"message": "OK", "code": 200}}}], "recorded_with": "betamax/0.7.0"} \ No newline at end of file diff --git a/tests/cassettes/tests.components.sensor.test_yr.TestSensorYr.test_default_setup.json b/tests/cassettes/tests.components.sensor.test_yr.TestSensorYr.test_default_setup.json index 4ff2ff18df53d..8226cbbf96e20 100644 --- a/tests/cassettes/tests.components.sensor.test_yr.TestSensorYr.test_default_setup.json +++ b/tests/cassettes/tests.components.sensor.test_yr.TestSensorYr.test_default_setup.json @@ -1 +1 @@ -{"http_interactions": [{"recorded_at": "2015-12-28T01:34:34", "request": {"method": "GET", "headers": {"Accept": ["*/*"], "Accept-Encoding": ["gzip, deflate"], "User-Agent": ["python-requests/2.9.1"], "Connection": ["keep-alive"]}, "body": {"string": "", "encoding": "utf-8"}, "uri": "http://api.yr.no/weatherapi/locationforecast/1.9/?lat=32.87336;lon=117.22743;msl=0"}, "response": {"headers": {"Content-Length": ["3598"], "X-forecast-models": ["proff,ecdet"], "Via": ["1.1 varnish"], "Content-Encoding": ["gzip"], "Date": ["Mon, 28 Dec 2015 01:34:33 GMT"], "X-Varnish": ["2670913258 2670013167"], "Expires": ["Mon, 28 Dec 2015 02:01:51 GMT"], "Server": ["Apache"], "Age": ["1573"], "Content-Type": ["text/xml; charset=utf-8"], "X-Backend-Host": ["snipe_loc"], "X-slicenumber": ["30"], "Accept-Ranges": ["bytes"], "Last-Modified": ["Mon, 28 Dec 2015 01:08:20 GMT"], "Vary": ["Accept-Encoding"], "Connection": ["keep-alive"]}, "url": "http://api.yr.no/weatherapi/locationforecast/1.9/?lat=32.87336;lon=117.22743;msl=0", "body": {"base64_string": "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", "encoding": "utf-8"}, "status": {"code": 200, "message": "OK"}}}], "recorded_with": "betamax/0.5.1"} \ No newline at end of file +{"http_interactions": [{"request": {"uri": "http://api.yr.no/weatherapi/locationforecast/1.9/?lat=32.87336;lon=117.22743;msl=0", "method": "GET", "headers": {"Accept": ["*/*"], "User-Agent": ["python-requests/2.9.1"], "Accept-Encoding": ["gzip, deflate"], "Connection": ["keep-alive"]}, "body": {"encoding": "utf-8", "string": ""}}, "recorded_at": "2016-06-09T04:02:22", "response": {"url": "http://api.yr.no/weatherapi/locationforecast/1.9/?lat=32.87336;lon=117.22743;msl=0", "headers": {"Location": ["http://api.met.no/weatherapi/locationforecast/1.9/?lat=32.87336;lon=117.22743;msl=0"], "Age": ["0"], "X-Varnish": ["4249779869"], "Via": ["1.1 varnish"], "Server": ["Varnish"], "Date": ["Thu, 09 Jun 2016 04:02:20 GMT"], "Connection": ["close"], "Accept-Ranges": ["bytes"]}, "body": {"encoding": null, "string": ""}, "status": {"message": "Moved permanently", "code": 301}}}, {"request": {"uri": "http://api.met.no/weatherapi/locationforecast/1.9/?lat=32.87336;lon=117.22743;msl=0", "method": "GET", "headers": {"Accept": ["*/*"], "User-Agent": ["python-requests/2.9.1"], "Accept-Encoding": ["gzip, deflate"], "Connection": ["keep-alive"]}, "body": {"encoding": "utf-8", "string": ""}}, "recorded_at": "2016-06-09T04:02:22", "response": {"url": "http://api.met.no/weatherapi/locationforecast/1.9/?lat=32.87336;lon=117.22743;msl=0", "headers": {"Content-Length": ["3637"], "X-Backend-Host": ["ravn_loc"], "X-Varnish": ["4249780502"], "Via": ["1.1 varnish"], "Last-Modified": ["Thu, 09 Jun 2016 04:02:21 GMT"], "X-forecast-models": ["proff,ecdet"], "Accept-Ranges": ["bytes"], "Expires": ["Thu, 09 Jun 2016 05:01:15 GMT"], "Content-Encoding": ["gzip"], "Age": ["0"], "Content-Type": ["text/xml; charset=utf-8"], "Server": ["Apache"], "Vary": ["Accept-Encoding"], "Date": ["Thu, 09 Jun 2016 04:02:21 GMT"], "X-slicenumber": ["83"], "Connection": ["keep-alive"]}, "body": {"encoding": "utf-8", "base64_string": "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"}, "status": {"message": "OK", "code": 200}}}], "recorded_with": "betamax/0.7.0"} \ No newline at end of file diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py new file mode 100644 index 0000000000000..210ce2c58faa4 --- /dev/null +++ b/tests/components/device_tracker/test_asuswrt.py @@ -0,0 +1,69 @@ +"""The tests for the ASUSWRT device tracker platform.""" + +import os +import unittest +from unittest import mock + +from homeassistant.components import device_tracker +from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, + CONF_HOST) + +from tests.common import get_test_home_assistant + + +class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): + """Tests for the ASUSWRT device tracker platform.""" + + 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.""" + try: + os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) + except FileNotFoundError: + pass + + def test_password_or_pub_key_required(self): + """Test creating an AsusWRT scanner without a pass or pubkey.""" + self.assertIsNone(device_tracker.asuswrt.get_scanner( + self.hass, {device_tracker.DOMAIN: { + CONF_PLATFORM: 'asuswrt', + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user' + }})) + + @mock.patch( + 'homeassistant.components.device_tracker.asuswrt.AsusWrtDeviceScanner', + return_value=mock.MagicMock()) + def test_get_scanner_with_password_no_pubkey(self, asuswrt_mock): + """Test creating an AsusWRT scanner with a password and no pubkey.""" + conf_dict = { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'asuswrt', + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user', + CONF_PASSWORD: 'fake_pass' + } + } + self.assertIsNotNone(device_tracker.asuswrt.get_scanner( + self.hass, conf_dict)) + asuswrt_mock.assert_called_once_with(conf_dict[device_tracker.DOMAIN]) + + @mock.patch( + 'homeassistant.components.device_tracker.asuswrt.AsusWrtDeviceScanner', + return_value=mock.MagicMock()) + def test_get_scanner_with_pubkey_no_password(self, asuswrt_mock): + """Test creating an AsusWRT scanner with a pubkey and no password.""" + conf_dict = { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'asuswrt', + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user', + 'pub_key': '/fake_path' + } + } + self.assertIsNotNone(device_tracker.asuswrt.get_scanner( + self.hass, conf_dict)) + asuswrt_mock.assert_called_once_with(conf_dict[device_tracker.DOMAIN]) diff --git a/tests/components/device_tracker/test_bt_home_hub_5.py b/tests/components/device_tracker/test_bt_home_hub_5.py new file mode 100644 index 0000000000000..fd9692ec2b47c --- /dev/null +++ b/tests/components/device_tracker/test_bt_home_hub_5.py @@ -0,0 +1,53 @@ +"""The tests for the BT Home Hub 5 device tracker platform.""" +import unittest +from unittest.mock import patch + +from homeassistant.components.device_tracker import bt_home_hub_5 +from homeassistant.const import CONF_HOST + +patch_file = 'homeassistant.components.device_tracker.bt_home_hub_5' + + +def _get_homehub_data(url): + """Return mock homehub data.""" + return ''' + [ + { + "mac": "AA:BB:CC:DD:EE:FF, + "hostname": "hostname", + "ip": "192.168.1.43", + "ipv6": "", + "name": "hostname", + "activity": "1", + "os": "Unknown", + "device": "Unknown", + "time_first_seen": "2016/06/05 11:14:45", + "time_last_active": "2016/06/06 11:33:08", + "dhcp_option": "39043T90430T9TGK0EKGE5KGE3K904390K45GK054", + "port": "wl0", + "ipv6_ll": "fe80::gd67:ghrr:fuud:4332", + "activity_ip": "1", + "activity_ipv6_ll": "0", + "activity_ipv6": "0", + "device_oui": "NA", + "device_serial": "NA", + "device_class": "NA" + } + ] + ''' + + +class TestBTHomeHub5DeviceTracker(unittest.TestCase): + """Test BT Home Hub 5 device tracker platform.""" + + @patch('{}._get_homehub_data'.format(patch_file), new=_get_homehub_data) + def test_config_minimal(self): + """Test the setup with minimal configuration.""" + config = { + 'device_tracker': { + CONF_HOST: 'foo' + } + } + result = bt_home_hub_5.get_scanner(None, config) + + self.assertIsNotNone(result) diff --git a/tests/components/sensor/test_yr.py b/tests/components/sensor/test_yr.py index c60324e38b94d..43a1457869044 100644 --- a/tests/components/sensor/test_yr.py +++ b/tests/components/sensor/test_yr.py @@ -25,7 +25,7 @@ def teardown_method(self, method): def test_default_setup(self, betamax_session): """Test the default setup.""" - now = datetime(2016, 1, 5, 1, tzinfo=dt_util.UTC) + now = datetime(2016, 6, 9, 1, tzinfo=dt_util.UTC) with patch('homeassistant.components.sensor.yr.requests.Session', return_value=betamax_session): @@ -37,13 +37,13 @@ def test_default_setup(self, betamax_session): state = self.hass.states.get('sensor.yr_symbol') - assert '46' == state.state + assert '3' == state.state assert state.state.isnumeric() assert state.attributes.get('unit_of_measurement') is None def test_custom_setup(self, betamax_session): """Test a custom setup.""" - now = datetime(2016, 1, 5, 1, tzinfo=dt_util.UTC) + now = datetime(2016, 6, 9, 1, tzinfo=dt_util.UTC) with patch('homeassistant.components.sensor.yr.requests.Session', return_value=betamax_session): @@ -61,15 +61,15 @@ def test_custom_setup(self, betamax_session): state = self.hass.states.get('sensor.yr_pressure') assert 'hPa' == state.attributes.get('unit_of_measurement') - assert '1025.1' == state.state + assert '1009.3' == state.state state = self.hass.states.get('sensor.yr_wind_direction') assert '°' == state.attributes.get('unit_of_measurement') - assert '81.8' == state.state + assert '103.6' == state.state state = self.hass.states.get('sensor.yr_humidity') assert '%' == state.attributes.get('unit_of_measurement') - assert '79.6' == state.state + assert '55.5' == state.state state = self.hass.states.get('sensor.yr_fog') assert '%' == state.attributes.get('unit_of_measurement') @@ -77,4 +77,4 @@ def test_custom_setup(self, betamax_session): state = self.hass.states.get('sensor.yr_wind_speed') assert 'm/s', state.attributes.get('unit_of_measurement') - assert '4.3' == state.state + assert '3.5' == state.state diff --git a/tests/components/test_configurator.py b/tests/components/test_configurator.py index d45f64cf2adb0..3b91c28b3a5dc 100644 --- a/tests/components/test_configurator.py +++ b/tests/components/test_configurator.py @@ -3,7 +3,7 @@ import unittest import homeassistant.components.configurator as configurator -from homeassistant.const import EVENT_TIME_CHANGED +from homeassistant.const import EVENT_TIME_CHANGED, ATTR_FRIENDLY_NAME from tests.common import get_test_home_assistant @@ -40,26 +40,25 @@ def test_request_least_info(self): def test_request_all_info(self): """Test request config with all possible info.""" - values = [ - "config_description", "config image url", - "config submit caption", []] - - keys = [ - configurator.ATTR_DESCRIPTION, configurator.ATTR_DESCRIPTION_IMAGE, - configurator.ATTR_SUBMIT_CAPTION, configurator.ATTR_FIELDS] - - exp_attr = dict(zip(keys, values)) - - exp_attr[configurator.ATTR_CONFIGURE_ID] = configurator.request_config( - self.hass, "Test Request", lambda _: None, - *values) + exp_attr = { + ATTR_FRIENDLY_NAME: "Test Request", + configurator.ATTR_DESCRIPTION: "config description", + configurator.ATTR_DESCRIPTION_IMAGE: "config image url", + configurator.ATTR_SUBMIT_CAPTION: "config submit caption", + configurator.ATTR_FIELDS: [], + configurator.ATTR_CONFIGURE_ID: configurator.request_config( + self.hass, "Test Request", lambda _: None, + "config description", "config image url", + "config submit caption" + ) + } states = self.hass.states.all() self.assertEqual(1, len(states)) state = states[0] self.assertEqual(configurator.STATE_CONFIGURE, state.state) - self.assertEqual(exp_attr, state.attributes) + assert exp_attr == dict(state.attributes) def test_callback_called_on_configure(self): """Test if our callback gets called when configure service called.""" diff --git a/tests/components/test_forecast.py b/tests/components/test_forecast.py new file mode 100644 index 0000000000000..bfda22596c2c5 --- /dev/null +++ b/tests/components/test_forecast.py @@ -0,0 +1,76 @@ +"""The tests for the forecast.io platform.""" +import json +import re +import os +import unittest +from unittest.mock import MagicMock, patch + +import forecastio +import httpretty +from requests.exceptions import HTTPError + +from homeassistant.components.sensor import forecast +from homeassistant import core as ha + + +class TestForecastSetup(unittest.TestCase): + """Test the forecast.io platform.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = ha.HomeAssistant() + self.key = 'foo' + self.config = { + 'api_key': 'foo', + 'monitored_conditions': ['summary', 'icon'] + } + self.lat = 37.8267 + self.lon = -122.423 + self.hass.config.latitude = self.lat + self.hass.config.longitude = self.lon + + def test_setup_no_latitude(self): + """Test that the component is not loaded without required config.""" + self.hass.config.latitude = None + self.assertFalse(forecast.setup_platform(self.hass, {}, MagicMock())) + + @patch('forecastio.api.get_forecast') + def test_setup_bad_api_key(self, mock_get_forecast): + """Test for handling a bad API key.""" + # The forecast API wrapper that we use raises an HTTP error + # when you try to use a bad (or no) API key. + url = 'https://api.forecast.io/forecast/{}/{},{}?units=auto'.format( + self.key, str(self.lat), str(self.lon) + ) + msg = '400 Client Error: Bad Request for url: {}'.format(url) + mock_get_forecast.side_effect = HTTPError(msg,) + + response = forecast.setup_platform(self.hass, self.config, MagicMock()) + self.assertFalse(response) + + @httpretty.activate + @patch('forecastio.api.get_forecast', wraps=forecastio.api.get_forecast) + def test_setup(self, mock_get_forecast): + """Test for successfully setting up the forecast.io platform.""" + def load_fixture_from_json(): + cwd = os.path.dirname(__file__) + fixture_path = os.path.join(cwd, '..', 'fixtures', 'forecast.json') + with open(fixture_path) as file: + content = json.load(file) + return json.dumps(content) + + # Mock out any calls to the actual API and + # return the fixture json instead + uri = 'api.forecast.io\/forecast\/(\w+)\/(-?\d+\.?\d*),(-?\d+\.?\d*)' + httpretty.register_uri( + httpretty.GET, + re.compile(uri), + body=load_fixture_from_json(), + ) + # The following will raise an error if the regex for the mock was + # incorrect and we actually try to go out to the internet. + httpretty.HTTPretty.allow_net_connect = False + + forecast.setup_platform(self.hass, self.config, MagicMock()) + self.assertTrue(mock_get_forecast.called) + self.assertEqual(mock_get_forecast.call_count, 1) diff --git a/tests/components/test_shell_command.py b/tests/components/test_shell_command.py index a313a41b66afe..0318ef4742add 100644 --- a/tests/components/test_shell_command.py +++ b/tests/components/test_shell_command.py @@ -51,6 +51,30 @@ def test_config_not_valid_service_names(self): } }) + def test_template_render_no_template(self): + """Ensure shell_commands without templates get rendered properly.""" + cmd, shell = shell_command._parse_command(self.hass, 'ls /bin', {}) + self.assertTrue(shell) + self.assertEqual(cmd, 'ls /bin') + + def test_template_render(self): + """Ensure shell_commands with templates get rendered properly.""" + self.hass.states.set('sensor.test_state', 'Works') + cmd, shell = shell_command._parse_command( + self.hass, + 'ls /bin {{ states.sensor.test_state.state }}', {} + ) + self.assertFalse(shell, False) + self.assertEqual(cmd[-1], 'Works') + + def test_invalid_template_fails(self): + """Test that shell_commands with invalid templates fail.""" + cmd, _shell = shell_command._parse_command( + self.hass, + 'ls /bin {{ states. .test_state.state }}', {} + ) + self.assertEqual(cmd, None) + @patch('homeassistant.components.shell_command.subprocess.call', side_effect=SubprocessError) @patch('homeassistant.components.shell_command._LOGGER.error') diff --git a/tests/fixtures/forecast.json b/tests/fixtures/forecast.json new file mode 100644 index 0000000000000..01c66cae07803 --- /dev/null +++ b/tests/fixtures/forecast.json @@ -0,0 +1,1462 @@ +{ + "alerts": [ + { + "description": "...BEACH HAZARDS STATEMENT REMAINS IN EFFECT UNTIL 9 PM PDT THIS\nEVENING...\n* HAZARDS...STRONG RIP CURRENTS AND LARGE SHORE BREAK.\n* TIMING...THROUGH THIS EVENING.\n* LOCATION...COASTLINE FROM SONOMA COUNTY SOUTH THROUGH MONTEREY\nCOUNTY. IN PARTICULAR SOUTHWEST FACING BEACHES...INCLUDING BUT\nNOT LIMITED TO...STINSON BEACH...SANTA CRUZ BOARDWALK BEACH\nAND TWIN LAKES BEACH.\n* POTENTIAL IMPACTS...STRONG RIP CURRENTS CAN PULL EVEN THE\nSTRONGEST SWIMMERS AWAY FROM SHORE. LARGE SHORE BREAK CAN\nRESULT IN SERIOUS NECK AND BACK INJURIES.\n", + "expires": 1464926400, + "time": 1464904560, + "title": "Beach Hazards Statement for San Francisco, CA", + "uri": "http://alerts.weather.gov/cap/wwacapget.php?x=CA1256018894B0.BeachHazardsStatement.125601952900CA.MTRCFWMTR.aba33b94542100e878a14a443e621995" + } + ], + "currently": { + "apparentTemperature": 68.1, + "cloudCover": 0.18, + "dewPoint": 53.23, + "humidity": 0.59, + "icon": "clear-day", + "nearestStormBearing": 115, + "nearestStormDistance": 325, + "ozone": 322.71, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1014.8, + "summary": "Clear", + "temperature": 68.1, + "time": 1464914163, + "visibility": 9.02, + "windBearing": 271, + "windSpeed": 9.38 + }, + "daily": { + "data": [ + { + "apparentTemperatureMax": 68.56, + "apparentTemperatureMaxTime": 1464915600, + "apparentTemperatureMin": 52.94, + "apparentTemperatureMinTime": 1464872400, + "cloudCover": 0.23, + "dewPoint": 51.98, + "humidity": 0.77, + "icon": "partly-cloudy-day", + "moonPhase": 0.91, + "ozone": 326.1, + "precipIntensity": 0, + "precipIntensityMax": 0, + "precipProbability": 0, + "pressure": 1014.84, + "summary": "Partly cloudy in the morning.", + "sunriseTime": 1464871812, + "sunsetTime": 1464924498, + "temperatureMax": 68.56, + "temperatureMaxTime": 1464915600, + "temperatureMin": 52.94, + "temperatureMinTime": 1464872400, + "time": 1464850800, + "visibility": 7.8, + "windBearing": 268, + "windSpeed": 5.59 + }, + { + "apparentTemperatureMax": 75.82, + "apparentTemperatureMaxTime": 1464991200, + "apparentTemperatureMin": 53.45, + "apparentTemperatureMinTime": 1464958800, + "cloudCover": 0.41, + "dewPoint": 53.58, + "humidity": 0.71, + "icon": "partly-cloudy-day", + "moonPhase": 0.95, + "ozone": 319.98, + "precipIntensity": 0, + "precipIntensityMax": 0, + "precipProbability": 0, + "pressure": 1015.2, + "summary": "Partly cloudy throughout the day.", + "sunriseTime": 1464958194, + "sunsetTime": 1465010936, + "temperatureMax": 75.82, + "temperatureMaxTime": 1464991200, + "temperatureMin": 53.45, + "temperatureMinTime": 1464958800, + "time": 1464937200, + "visibility": 9.24, + "windBearing": 274, + "windSpeed": 5.92 + }, + { + "apparentTemperatureMax": 72.18, + "apparentTemperatureMaxTime": 1465081200, + "apparentTemperatureMin": 53.06, + "apparentTemperatureMinTime": 1465038000, + "cloudCover": 0.74, + "dewPoint": 54.14, + "humidity": 0.78, + "icon": "partly-cloudy-day", + "moonPhase": 0.98, + "ozone": 324.21, + "precipIntensity": 0, + "precipIntensityMax": 0, + "precipProbability": 0, + "pressure": 1013.17, + "summary": "Mostly cloudy throughout the day.", + "sunriseTime": 1465044577, + "sunsetTime": 1465097372, + "temperatureMax": 72.18, + "temperatureMaxTime": 1465081200, + "temperatureMin": 53.06, + "temperatureMinTime": 1465038000, + "time": 1465023600, + "visibility": 7.94, + "windBearing": 255, + "windSpeed": 5.5 + }, + { + "apparentTemperatureMax": 71.76, + "apparentTemperatureMaxTime": 1465171200, + "apparentTemperatureMin": 52.37, + "apparentTemperatureMinTime": 1465131600, + "cloudCover": 0.5, + "dewPoint": 53.42, + "humidity": 0.8, + "icon": "fog", + "moonPhase": 0.03, + "ozone": 325.96, + "precipIntensity": 0.0006, + "precipIntensityMax": 0.0016, + "precipIntensityMaxTime": 1465135200, + "precipProbability": 0.04, + "precipType": "rain", + "pressure": 1011.43, + "summary": "Foggy in the morning.", + "sunriseTime": 1465130962, + "sunsetTime": 1465183808, + "temperatureMax": 71.76, + "temperatureMaxTime": 1465171200, + "temperatureMin": 52.37, + "temperatureMinTime": 1465131600, + "time": 1465110000, + "visibility": 6.86, + "windBearing": 252, + "windSpeed": 7.29 + }, + { + "apparentTemperatureMax": 69.01, + "apparentTemperatureMaxTime": 1465246800, + "apparentTemperatureMin": 54.75, + "apparentTemperatureMinTime": 1465214400, + "cloudCover": 0.09, + "dewPoint": 52.16, + "humidity": 0.74, + "icon": "partly-cloudy-night", + "moonPhase": 0.07, + "ozone": 305.72, + "precipIntensity": 0, + "precipIntensityMax": 0, + "precipProbability": 0, + "pressure": 1006.94, + "summary": "Partly cloudy starting in the evening.", + "sunriseTime": 1465217348, + "sunsetTime": 1465270242, + "temperatureMax": 69.01, + "temperatureMaxTime": 1465246800, + "temperatureMin": 54.75, + "temperatureMinTime": 1465214400, + "time": 1465196400, + "windBearing": 222, + "windSpeed": 5.86 + }, + { + "apparentTemperatureMax": 67.78, + "apparentTemperatureMaxTime": 1465333200, + "apparentTemperatureMin": 55.38, + "apparentTemperatureMinTime": 1465300800, + "cloudCover": 0.34, + "dewPoint": 51.41, + "humidity": 0.73, + "icon": "partly-cloudy-day", + "moonPhase": 0.1, + "ozone": 304.57, + "precipIntensity": 0, + "precipIntensityMax": 0, + "precipProbability": 0, + "pressure": 1007.88, + "summary": "Partly cloudy throughout the day.", + "sunriseTime": 1465303737, + "sunsetTime": 1465356676, + "temperatureMax": 67.78, + "temperatureMaxTime": 1465333200, + "temperatureMin": 55.38, + "temperatureMinTime": 1465300800, + "time": 1465282800, + "windBearing": 224, + "windSpeed": 6.75 + }, + { + "apparentTemperatureMax": 68.94, + "apparentTemperatureMaxTime": 1465416000, + "apparentTemperatureMin": 55.11, + "apparentTemperatureMinTime": 1465452000, + "cloudCover": 0.45, + "dewPoint": 47.11, + "humidity": 0.63, + "icon": "partly-cloudy-day", + "moonPhase": 0.13, + "ozone": 329.52, + "precipIntensity": 0, + "precipIntensityMax": 0, + "precipProbability": 0, + "pressure": 1010.78, + "summary": "Mostly cloudy until afternoon.", + "sunriseTime": 1465390127, + "sunsetTime": 1465443107, + "temperatureMax": 68.94, + "temperatureMaxTime": 1465416000, + "temperatureMin": 55.11, + "temperatureMinTime": 1465452000, + "time": 1465369200, + "windBearing": 263, + "windSpeed": 9.55 + }, + { + "apparentTemperatureMax": 65.67, + "apparentTemperatureMaxTime": 1465506000, + "apparentTemperatureMin": 54, + "apparentTemperatureMinTime": 1465470000, + "cloudCover": 0, + "dewPoint": 44.72, + "humidity": 0.61, + "icon": "clear-day", + "moonPhase": 0.17, + "ozone": 355.02, + "precipIntensity": 0, + "precipIntensityMax": 0, + "precipProbability": 0, + "pressure": 1010.11, + "summary": "Clear throughout the day.", + "sunriseTime": 1465476519, + "sunsetTime": 1465529538, + "temperatureMax": 65.67, + "temperatureMaxTime": 1465506000, + "temperatureMin": 54, + "temperatureMinTime": 1465470000, + "time": 1465455600, + "windBearing": 288, + "windSpeed": 12.21 + } + ], + "icon": "clear-day", + "summary": "No precipitation throughout the week, with temperatures falling to 66°F on Thursday." + }, + "flags": { + "darksky-stations": [ + "KMUX", + "KDAX" + ], + "isd-stations": [ + "724943-99999", + "745039-99999", + "745065-99999", + "994016-99999", + "998479-99999" + ], + "lamp-stations": [ + "KAPC", + "KCCR", + "KHWD", + "KLVK", + "KNUQ", + "KOAK", + "KPAO", + "KSFO", + "KSQL" + ], + "madis-stations": [ + "AU915", + "C5988", + "C6328", + "C8158", + "C9629", + "D5422", + "D8533", + "E0426", + "E6067", + "E9227", + "FTPC1", + "GGBC1", + "OKXC1", + "PPXC1", + "PXOC1", + "SFOC1" + ], + "sources": [ + "darksky", + "lamp", + "gfs", + "cmc", + "nam", + "rap", + "rtma", + "sref", + "fnmoc", + "isd", + "nwspa", + "madis", + "nearest-precip" + ], + "units": "us" + }, + "hourly": { + "data": [ + { + "apparentTemperature": 67.42, + "cloudCover": 0.19, + "dewPoint": 52.31, + "humidity": 0.58, + "icon": "clear-day", + "ozone": 322.76, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1014.88, + "summary": "Clear", + "temperature": 67.42, + "time": 1464912000, + "visibility": 9, + "windBearing": 269, + "windSpeed": 8.38 + }, + { + "apparentTemperature": 68.56, + "cloudCover": 0.18, + "dewPoint": 53.84, + "humidity": 0.59, + "icon": "clear-day", + "ozone": 322.68, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1014.76, + "summary": "Clear", + "temperature": 68.56, + "time": 1464915600, + "visibility": 9.03, + "windBearing": 272, + "windSpeed": 10.05 + }, + { + "apparentTemperature": 67.39, + "cloudCover": 0.15, + "dewPoint": 54.53, + "humidity": 0.63, + "icon": "clear-day", + "ozone": 322.66, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1014.75, + "summary": "Clear", + "temperature": 67.39, + "time": 1464919200, + "visibility": 9.31, + "windBearing": 274, + "windSpeed": 9.2 + }, + { + "apparentTemperature": 65.48, + "cloudCover": 0.13, + "dewPoint": 53.73, + "humidity": 0.66, + "icon": "clear-day", + "ozone": 322.72, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1014.86, + "summary": "Clear", + "temperature": 65.48, + "time": 1464922800, + "visibility": 9.41, + "windBearing": 276, + "windSpeed": 8.41 + }, + { + "apparentTemperature": 63.37, + "cloudCover": 0.15, + "dewPoint": 53.05, + "humidity": 0.69, + "icon": "clear-night", + "ozone": 322.89, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.18, + "summary": "Clear", + "temperature": 63.37, + "time": 1464926400, + "visibility": 9.64, + "windBearing": 277, + "windSpeed": 6.64 + }, + { + "apparentTemperature": 61.63, + "cloudCover": 0.18, + "dewPoint": 52.33, + "humidity": 0.72, + "icon": "clear-night", + "ozone": 323.15, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.62, + "summary": "Clear", + "temperature": 61.63, + "time": 1464930000, + "visibility": 9.65, + "windBearing": 280, + "windSpeed": 5.84 + }, + { + "apparentTemperature": 59.39, + "cloudCover": 0.2, + "dewPoint": 51.07, + "humidity": 0.74, + "icon": "clear-night", + "ozone": 323.5, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.9, + "summary": "Clear", + "temperature": 59.39, + "time": 1464933600, + "visibility": 9.62, + "windBearing": 281, + "windSpeed": 5.43 + }, + { + "apparentTemperature": 58.44, + "cloudCover": 0.2, + "dewPoint": 50.88, + "humidity": 0.76, + "icon": "clear-night", + "ozone": 323.99, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.87, + "summary": "Clear", + "temperature": 58.44, + "time": 1464937200, + "visibility": 9.5, + "windBearing": 279, + "windSpeed": 4.98 + }, + { + "apparentTemperature": 57.85, + "cloudCover": 0.2, + "dewPoint": 51.05, + "humidity": 0.78, + "icon": "clear-night", + "ozone": 324.56, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.69, + "summary": "Clear", + "temperature": 57.85, + "time": 1464940800, + "visibility": 9.11, + "windBearing": 278, + "windSpeed": 5.02 + }, + { + "apparentTemperature": 57.28, + "cloudCover": 0.22, + "dewPoint": 51.09, + "humidity": 0.8, + "icon": "clear-night", + "ozone": 325.1, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.52, + "summary": "Clear", + "temperature": 57.28, + "time": 1464944400, + "visibility": 9.05, + "windBearing": 278, + "windSpeed": 5.14 + }, + { + "apparentTemperature": 56.24, + "cloudCover": 0.27, + "dewPoint": 50.63, + "humidity": 0.81, + "icon": "partly-cloudy-night", + "ozone": 325.68, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.37, + "summary": "Partly Cloudy", + "temperature": 56.24, + "time": 1464948000, + "visibility": 8.85, + "windBearing": 278, + "windSpeed": 4.82 + }, + { + "apparentTemperature": 54.93, + "cloudCover": 0.34, + "dewPoint": 49.82, + "humidity": 0.83, + "icon": "partly-cloudy-night", + "ozone": 326.22, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.25, + "summary": "Partly Cloudy", + "temperature": 54.93, + "time": 1464951600, + "visibility": 8.56, + "windBearing": 278, + "windSpeed": 4.45 + }, + { + "apparentTemperature": 54.06, + "cloudCover": 0.4, + "dewPoint": 49.16, + "humidity": 0.83, + "icon": "partly-cloudy-night", + "ozone": 326.31, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.22, + "summary": "Partly Cloudy", + "temperature": 54.06, + "time": 1464955200, + "visibility": 8.12, + "windBearing": 280, + "windSpeed": 4.13 + }, + { + "apparentTemperature": 53.45, + "cloudCover": 0.45, + "dewPoint": 48.47, + "humidity": 0.83, + "icon": "partly-cloudy-day", + "ozone": 325.68, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.4, + "summary": "Partly Cloudy", + "temperature": 53.45, + "time": 1464958800, + "visibility": 7.76, + "windBearing": 280, + "windSpeed": 3.86 + }, + { + "apparentTemperature": 56.05, + "cloudCover": 0.5, + "dewPoint": 50.07, + "humidity": 0.8, + "icon": "partly-cloudy-day", + "ozone": 324.6, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.68, + "summary": "Partly Cloudy", + "temperature": 56.05, + "time": 1464962400, + "visibility": 7.77, + "windBearing": 279, + "windSpeed": 3.61 + }, + { + "apparentTemperature": 59.38, + "cloudCover": 0.52, + "dewPoint": 51.39, + "humidity": 0.75, + "icon": "partly-cloudy-day", + "ozone": 323.51, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.9, + "summary": "Partly Cloudy", + "temperature": 59.38, + "time": 1464966000, + "visibility": 8.18, + "windBearing": 275, + "windSpeed": 4 + }, + { + "apparentTemperature": 62.67, + "cloudCover": 0.51, + "dewPoint": 52.44, + "humidity": 0.69, + "icon": "partly-cloudy-day", + "ozone": 322.57, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.97, + "summary": "Partly Cloudy", + "temperature": 62.67, + "time": 1464969600, + "visibility": 8.4, + "windBearing": 272, + "windSpeed": 4.22 + }, + { + "apparentTemperature": 65.51, + "cloudCover": 0.49, + "dewPoint": 52.62, + "humidity": 0.63, + "icon": "partly-cloudy-day", + "ozone": 321.61, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.96, + "summary": "Partly Cloudy", + "temperature": 65.51, + "time": 1464973200, + "visibility": 8.72, + "windBearing": 271, + "windSpeed": 4.65 + }, + { + "apparentTemperature": 66.98, + "cloudCover": 0.46, + "dewPoint": 53.53, + "humidity": 0.62, + "icon": "partly-cloudy-day", + "ozone": 320.6, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.9, + "summary": "Partly Cloudy", + "temperature": 66.98, + "time": 1464976800, + "visibility": 8.81, + "windBearing": 271, + "windSpeed": 4.93 + }, + { + "apparentTemperature": 69.34, + "cloudCover": 0.4, + "dewPoint": 55.04, + "humidity": 0.6, + "icon": "partly-cloudy-day", + "ozone": 319.39, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.8, + "summary": "Partly Cloudy", + "temperature": 69.34, + "time": 1464980400, + "visibility": 9.05, + "windBearing": 271, + "windSpeed": 5.7 + }, + { + "apparentTemperature": 71.92, + "cloudCover": 0.34, + "dewPoint": 56.61, + "humidity": 0.59, + "icon": "partly-cloudy-day", + "ozone": 318.12, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.65, + "summary": "Partly Cloudy", + "temperature": 71.92, + "time": 1464984000, + "visibility": 9.49, + "windBearing": 272, + "windSpeed": 6.7 + }, + { + "apparentTemperature": 74.45, + "cloudCover": 0.3, + "dewPoint": 58.29, + "humidity": 0.57, + "icon": "partly-cloudy-day", + "ozone": 317.16, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.43, + "summary": "Partly Cloudy", + "temperature": 74.45, + "time": 1464987600, + "visibility": 9.9, + "windBearing": 273, + "windSpeed": 7.6 + }, + { + "apparentTemperature": 75.82, + "cloudCover": 0.3, + "dewPoint": 59.26, + "humidity": 0.57, + "icon": "partly-cloudy-day", + "ozone": 316.77, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1015.09, + "summary": "Partly Cloudy", + "temperature": 75.82, + "time": 1464991200, + "visibility": 10, + "windBearing": 273, + "windSpeed": 8.58 + }, + { + "apparentTemperature": 74.92, + "cloudCover": 0.32, + "dewPoint": 58.55, + "humidity": 0.57, + "icon": "partly-cloudy-day", + "ozone": 316.68, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1014.71, + "summary": "Partly Cloudy", + "temperature": 74.92, + "time": 1464994800, + "visibility": 10, + "windBearing": 274, + "windSpeed": 9.12 + }, + { + "apparentTemperature": 73.33, + "cloudCover": 0.36, + "dewPoint": 57.88, + "humidity": 0.58, + "icon": "partly-cloudy-day", + "ozone": 316.48, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1014.4, + "summary": "Partly Cloudy", + "temperature": 73.33, + "time": 1464998400, + "visibility": 10, + "windBearing": 274, + "windSpeed": 9.21 + }, + { + "apparentTemperature": 70.53, + "cloudCover": 0.41, + "dewPoint": 56.98, + "humidity": 0.62, + "icon": "partly-cloudy-day", + "ozone": 316.04, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1014.22, + "summary": "Partly Cloudy", + "temperature": 70.53, + "time": 1465002000, + "visibility": 10, + "windBearing": 274, + "windSpeed": 8.77 + }, + { + "apparentTemperature": 67.03, + "cloudCover": 0.49, + "dewPoint": 55.91, + "humidity": 0.68, + "icon": "partly-cloudy-day", + "ozone": 315.48, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1014.11, + "summary": "Partly Cloudy", + "temperature": 67.03, + "time": 1465005600, + "visibility": 10, + "windBearing": 273, + "windSpeed": 7.84 + }, + { + "apparentTemperature": 63.9, + "cloudCover": 0.56, + "dewPoint": 54.93, + "humidity": 0.73, + "icon": "partly-cloudy-day", + "ozone": 314.77, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1014.1, + "summary": "Partly Cloudy", + "temperature": 63.9, + "time": 1465009200, + "visibility": 10, + "windBearing": 271, + "windSpeed": 7.12 + }, + { + "apparentTemperature": 61.49, + "cloudCover": 0.58, + "dewPoint": 54.23, + "humidity": 0.77, + "icon": "partly-cloudy-night", + "ozone": 313.81, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1014.29, + "summary": "Partly Cloudy", + "temperature": 61.49, + "time": 1465012800, + "visibility": 10, + "windBearing": 269, + "windSpeed": 6.5 + }, + { + "apparentTemperature": 59.39, + "cloudCover": 0.58, + "dewPoint": 53.62, + "humidity": 0.81, + "icon": "partly-cloudy-night", + "ozone": 312.7, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1014.54, + "summary": "Partly Cloudy", + "temperature": 59.39, + "time": 1465016400, + "visibility": 10, + "windBearing": 266, + "windSpeed": 5.92 + }, + { + "apparentTemperature": 58.02, + "cloudCover": 0.59, + "dewPoint": 53.35, + "humidity": 0.84, + "icon": "partly-cloudy-night", + "ozone": 311.6, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1014.63, + "summary": "Partly Cloudy", + "temperature": 58.02, + "time": 1465020000, + "visibility": 10, + "windBearing": 263, + "windSpeed": 5.46 + }, + { + "apparentTemperature": 57.06, + "cloudCover": 0.61, + "dewPoint": 53.07, + "humidity": 0.87, + "icon": "partly-cloudy-night", + "ozone": 310.41, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1014.43, + "summary": "Mostly Cloudy", + "temperature": 57.06, + "time": 1465023600, + "visibility": 9.01, + "windBearing": 259, + "windSpeed": 5.18 + }, + { + "apparentTemperature": 56.12, + "cloudCover": 0.63, + "dewPoint": 52.56, + "humidity": 0.88, + "icon": "partly-cloudy-night", + "ozone": 309.24, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1014.09, + "summary": "Mostly Cloudy", + "temperature": 56.12, + "time": 1465027200, + "visibility": 7.64, + "windBearing": 256, + "windSpeed": 5.01 + }, + { + "apparentTemperature": 55.14, + "cloudCover": 0.67, + "dewPoint": 51.94, + "humidity": 0.89, + "icon": "partly-cloudy-night", + "ozone": 308.58, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1013.74, + "summary": "Mostly Cloudy", + "temperature": 55.14, + "time": 1465030800, + "visibility": 6.64, + "windBearing": 253, + "windSpeed": 4.84 + }, + { + "apparentTemperature": 54.01, + "cloudCover": 0.72, + "dewPoint": 51.21, + "humidity": 0.9, + "icon": "partly-cloudy-night", + "ozone": 308.59, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1013.39, + "summary": "Mostly Cloudy", + "temperature": 54.01, + "time": 1465034400, + "visibility": 6.4, + "windBearing": 254, + "windSpeed": 4.64 + }, + { + "apparentTemperature": 53.06, + "cloudCover": 0.77, + "dewPoint": 50.57, + "humidity": 0.91, + "icon": "partly-cloudy-night", + "ozone": 309.11, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1013.05, + "summary": "Mostly Cloudy", + "temperature": 53.06, + "time": 1465038000, + "visibility": 6.53, + "windBearing": 256, + "windSpeed": 4.45 + }, + { + "apparentTemperature": 53.73, + "cloudCover": 0.83, + "dewPoint": 51.2, + "humidity": 0.91, + "icon": "partly-cloudy-night", + "ozone": 310.15, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1012.84, + "summary": "Mostly Cloudy", + "temperature": 53.73, + "time": 1465041600, + "visibility": 6.58, + "windBearing": 255, + "windSpeed": 4.23 + }, + { + "apparentTemperature": 54.88, + "cloudCover": 0.89, + "dewPoint": 51.88, + "humidity": 0.9, + "icon": "partly-cloudy-day", + "ozone": 312.09, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1012.85, + "summary": "Mostly Cloudy", + "temperature": 54.88, + "time": 1465045200, + "visibility": 6.28, + "windBearing": 250, + "windSpeed": 3.81 + }, + { + "apparentTemperature": 56.24, + "cloudCover": 0.96, + "dewPoint": 52.42, + "humidity": 0.87, + "icon": "cloudy", + "ozone": 314.55, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1013, + "summary": "Overcast", + "temperature": 56.24, + "time": 1465048800, + "visibility": 5.92, + "windBearing": 241, + "windSpeed": 3.23 + }, + { + "apparentTemperature": 57.62, + "cloudCover": 1, + "dewPoint": 52.73, + "humidity": 0.84, + "icon": "cloudy", + "ozone": 316.51, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1013.16, + "summary": "Overcast", + "temperature": 57.62, + "time": 1465052400, + "visibility": 5.89, + "windBearing": 236, + "windSpeed": 3.11 + }, + { + "apparentTemperature": 59.22, + "cloudCover": 1, + "dewPoint": 52.86, + "humidity": 0.79, + "icon": "cloudy", + "ozone": 317.32, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1013.31, + "summary": "Overcast", + "temperature": 59.22, + "time": 1465056000, + "visibility": 6.54, + "windBearing": 241, + "windSpeed": 3.59 + }, + { + "apparentTemperature": 61.29, + "cloudCover": 0.99, + "dewPoint": 53.12, + "humidity": 0.75, + "icon": "cloudy", + "ozone": 317.63, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1013.46, + "summary": "Overcast", + "temperature": 61.29, + "time": 1465059600, + "visibility": 7.52, + "windBearing": 250, + "windSpeed": 4.42 + }, + { + "apparentTemperature": 63.6, + "cloudCover": 0.97, + "dewPoint": 53.81, + "humidity": 0.7, + "icon": "cloudy", + "ozone": 318.31, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1013.54, + "summary": "Overcast", + "temperature": 63.6, + "time": 1465063200, + "visibility": 8.26, + "windBearing": 256, + "windSpeed": 5.05 + }, + { + "apparentTemperature": 65.59, + "cloudCover": 0.95, + "dewPoint": 54.62, + "humidity": 0.68, + "icon": "cloudy", + "ozone": 319.55, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1013.54, + "summary": "Overcast", + "temperature": 65.59, + "time": 1465066800, + "visibility": 8.42, + "windBearing": 257, + "windSpeed": 5.53 + }, + { + "apparentTemperature": 67.6, + "cloudCover": 0.91, + "dewPoint": 55.73, + "humidity": 0.66, + "icon": "partly-cloudy-day", + "ozone": 321.16, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1013.48, + "summary": "Mostly Cloudy", + "temperature": 67.6, + "time": 1465070400, + "visibility": 8.32, + "windBearing": 257, + "windSpeed": 5.95 + }, + { + "apparentTemperature": 69.48, + "cloudCover": 0.87, + "dewPoint": 56.92, + "humidity": 0.64, + "icon": "partly-cloudy-day", + "ozone": 323.49, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1013.34, + "summary": "Mostly Cloudy", + "temperature": 69.48, + "time": 1465074000, + "visibility": 8.36, + "windBearing": 257, + "windSpeed": 6.37 + }, + { + "apparentTemperature": 71.07, + "cloudCover": 0.85, + "dewPoint": 57.94, + "humidity": 0.63, + "icon": "partly-cloudy-day", + "ozone": 326.97, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1013.09, + "summary": "Mostly Cloudy", + "temperature": 71.07, + "time": 1465077600, + "visibility": 8.75, + "windBearing": 258, + "windSpeed": 6.88 + }, + { + "apparentTemperature": 72.18, + "cloudCover": 0.82, + "dewPoint": 58.77, + "humidity": 0.63, + "icon": "partly-cloudy-day", + "ozone": 331.17, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1012.79, + "summary": "Mostly Cloudy", + "temperature": 72.18, + "time": 1465081200, + "visibility": 9.28, + "windBearing": 260, + "windSpeed": 7.5 + }, + { + "apparentTemperature": 71.38, + "cloudCover": 0.75, + "dewPoint": 58.47, + "humidity": 0.64, + "icon": "partly-cloudy-day", + "ozone": 335.24, + "precipIntensity": 0, + "precipProbability": 0, + "pressure": 1012.58, + "summary": "Mostly Cloudy", + "temperature": 71.38, + "time": 1465084800, + "visibility": 9.67, + "windBearing": 260, + "windSpeed": 7.75 + } + ], + "icon": "partly-cloudy-day", + "summary": "Partly cloudy starting tonight." + }, + "latitude": 37.8267, + "longitude": -122.423, + "minutely": { + "data": [ + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464914160 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464914220 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464914280 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464914340 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464914400 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464914460 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464914520 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464914580 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464914640 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464914700 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464914760 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464914820 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464914880 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464914940 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464915000 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464915060 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464915120 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464915180 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464915240 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464915300 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464915360 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464915420 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464915480 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464915540 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464915600 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464915660 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464915720 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464915780 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464915840 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464915900 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464915960 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464916020 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464916080 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464916140 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464916200 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464916260 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464916320 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464916380 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464916440 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464916500 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464916560 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464916620 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464916680 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464916740 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464916800 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464916860 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464916920 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464916980 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464917040 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464917100 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464917160 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464917220 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464917280 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464917340 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464917400 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464917460 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464917520 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464917580 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464917640 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464917700 + }, + { + "precipIntensity": 0, + "precipProbability": 0, + "time": 1464917760 + } + ], + "icon": "clear-day", + "summary": "Clear for the hour." + }, + "offset": -7, + "timezone": "America/Los_Angeles" +} diff --git a/tests/helpers/test_discovery.py b/tests/helpers/test_discovery.py new file mode 100644 index 0000000000000..bdc6e2ed11986 --- /dev/null +++ b/tests/helpers/test_discovery.py @@ -0,0 +1,90 @@ +"""Test discovery helpers.""" + +from unittest.mock import patch + +from homeassistant.helpers import discovery + +from tests.common import get_test_home_assistant + + +class TestHelpersDiscovery: + """Tests for discovery helper methods.""" + + 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() + + @patch('homeassistant.bootstrap.setup_component') + def test_listen(self, mock_setup_component): + """Test discovery listen/discover combo.""" + calls_single = [] + calls_multi = [] + + def callback_single(service, info): + """Service discovered callback.""" + calls_single.append((service, info)) + + def callback_multi(service, info): + """Service discovered callback.""" + calls_multi.append((service, info)) + + discovery.listen(self.hass, 'test service', callback_single) + discovery.listen(self.hass, ['test service', 'another service'], + callback_multi) + + discovery.discover(self.hass, 'test service', 'discovery info', + 'test_component') + self.hass.pool.block_till_done() + + discovery.discover(self.hass, 'another service', 'discovery info', + 'test_component') + self.hass.pool.block_till_done() + + assert mock_setup_component.called + assert mock_setup_component.call_args[0] == \ + (self.hass, 'test_component', None) + assert len(calls_single) == 1 + assert calls_single[0] == ('test service', 'discovery info') + + assert len(calls_single) == 1 + assert len(calls_multi) == 2 + assert ['test service', 'another service'] == [info[0] for info + in calls_multi] + + @patch('homeassistant.bootstrap.setup_component') + def test_platform(self, mock_setup_component): + """Test discover platform method.""" + calls = [] + + def platform_callback(platform, info): + """Platform callback method.""" + calls.append((platform, info)) + + discovery.listen_platform(self.hass, 'test_component', + platform_callback) + + discovery.load_platform(self.hass, 'test_component', 'test_platform', + 'discovery info') + assert mock_setup_component.called + assert mock_setup_component.call_args[0] == \ + (self.hass, 'test_component', None) + self.hass.pool.block_till_done() + + discovery.load_platform(self.hass, 'test_component_2', 'test_platform', + 'discovery info') + self.hass.pool.block_till_done() + + assert len(calls) == 1 + assert calls[0] == ('test_platform', 'discovery info') + + self.hass.bus.fire(discovery.EVENT_PLATFORM_DISCOVERED, { + discovery.ATTR_SERVICE: + discovery.EVENT_LOAD_PLATFORM.format('test_component') + }) + self.hass.pool.block_till_done() + + assert len(calls) == 1 diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 0b4b852d397db..a9a6310eb7998 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -9,7 +9,7 @@ import homeassistant.loader as loader from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.components import discovery +from homeassistant.helpers import discovery import homeassistant.util.dt as dt_util from tests.common import ( @@ -228,22 +228,17 @@ def test_setup_recovers_when_setup_raises(self): '._setup_platform') def test_setup_does_discovery(self, mock_setup): """Test setup for discovery.""" - component = EntityComponent( - _LOGGER, DOMAIN, self.hass, discovery_platforms={ - 'discovery.test': 'platform_test', - }) + component = EntityComponent(_LOGGER, DOMAIN, self.hass) component.setup({}) - self.hass.bus.fire(discovery.EVENT_PLATFORM_DISCOVERED, { - discovery.ATTR_SERVICE: 'discovery.test', - discovery.ATTR_DISCOVERED: 'discovery_info', - }) + discovery.load_platform(self.hass, DOMAIN, 'platform_test', + {'msg': 'discovery_info'}) self.hass.pool.block_till_done() assert mock_setup.called - assert ('platform_test', {}, 'discovery_info') == \ + assert ('platform_test', {}, {'msg': 'discovery_info'}) == \ mock_setup.call_args[0] @patch('homeassistant.helpers.entity_component.track_utc_time_change') diff --git a/tests/util/test_color.py b/tests/util/test_color.py index 82222a0b8a1a7..884b59ec10ec4 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -59,6 +59,22 @@ def test_rgb_hex_to_rgb_list(self): self.assertEqual([51, 153, 255, 0], color_util.rgb_hex_to_rgb_list('3399ff00')) + def test_color_name_to_rgb_valid_name(self): + """Test color_name_to_rgb.""" + self.assertEqual((255, 0, 0), + color_util.color_name_to_rgb('red')) + + self.assertEqual((0, 0, 255), + color_util.color_name_to_rgb('blue')) + + self.assertEqual((0, 255, 0), + color_util.color_name_to_rgb('green')) + + def test_color_name_to_rgb_unknown_name_default_white(self): + """Test color_name_to_rgb.""" + self.assertEqual((255, 255, 255), + color_util.color_name_to_rgb('not a color')) + class ColorTemperatureMiredToKelvinTests(unittest.TestCase): """Test color_temperature_mired_to_kelvin."""