From 44dae7af90538214c735b58d1293be4049f48e3f Mon Sep 17 00:00:00 2001 From: paul <31916694+spektren@users.noreply.github.com> Date: Thu, 19 Oct 2017 19:37:17 +0200 Subject: [PATCH] pull 20171019 (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Upgrade async_timeout to 1.4.0 (#9488) * Bump version of abodepy (#9491) * Upgrade coinmarketcap to 4.1.1 (#9490) * Upgrade blockchain to 1.4.0 (#9489) * Solve Recorder component failing when using Axis component (#9293) * Bump Axis requirement to v10 Fix issues related to non JSON serializable items and recorder component (8297) Add support to configure HTTP port (8403) * Changed local port definition to CONF_PORT * On request config is now sent to the camera platform as well, and in order better explain what is what the old internal config is now device_config and hass own config is the only one referenced as config * Missed to add device_config to setup in discovered device * Bump to V12 that has got a dependency fix * Update requirements_all * Add port configuration to automatically discovered devices Allow setup to pass without Axis being configured in configuration.yaml * switch to pypi for xiaomi gw (#9498) * renamed add_devices to async_add_devices according to hass naming scheme (#9485) * renamed add_devices to async_add_devices according to hass naming scheme * replaced some occurencies of async_add_entites to async_add_devices * fixed unit test * fixed unit test * Revert "renamed add_devices to async_add_devices according to hass naming scheme (#9485)" (#9503) This reverts commit a5a970709f02b5ef0be96af434371806863f81c7. * LIFX: fix multi-zone color restore after effects (#9492) The aiolifx 0.6.0 release fixes an issue where an effect color could remain set after stopping the effect. This only affected multi-zone lights (i.e. LIFX Z) and only if the effect was stopped by setting the light brightness or the color (but not both). The aiolifx 0.6.0 release also defaults end_index to start_index+7, so we can remove that argument. Finally, aiolifx_effects 0.1.2 adds support for aiolifx 0.6.0. * renamed add_devices to async_add_devices according to hass naming scheme (second try after failed #9485) (#9505) * Xiaomi pycryptodome (#9511) * Switch to use pycryptodome for xiaomi_gw * Bumped pyhomematic, additional device support (#9506) Add an optional extended description… * abode: Bump abodepy dependency to 0.11.7 (#9504) * abode: Bump abodepy dependency to 0.11.7 Fixes cases where one's abode account has a nest thermostat linked (https://github.com/MisterWil/abodepy/pull/17). * abode: Bump abodepy dependency to 0.11.7 Fixes cases where one's abode account has a nest thermostat linked (https://github.com/MisterWil/abodepy/pull/17). * update requirements_all.txt * abode: Set device_type in state attributes (#9515) This gets displayed when clicking on the binary sensors. It is useful to distinguish different devices with the same name (e.g. the room name) but different types. * update xiaomi aqara lib (#9520) * added services.yaml integration for input_boolean (#9519) * added services.yaml integration to input_boolean * added services integration for input_boolean * removed trailing spaces * Add reload service to python_script (#9512) * Add reload service * add reload test * Use global variable * remove white space .... * adjust as suggested * remove annoying white space.... * fix travis * fix travis, again * rename Load_scripts to Discover_scripts Travis complains that "Load_scripts" is an invalid name (I don't know why) * Update python_script.py * MQTT Binary Sensor - Add availability_topic for online/offline status (#9507) * MQTT Binary Sensor - Add availability_topic for online/offline status Added topic, configurable payloads, and tests. * Relocated state subscribe function Moved state subscribe function to follow the state listener function. * Fix typo within cover/knx https://github.com/XKNX/xknx/issues/64 (#9527) * LIFX: improve performance of setting multi-zone lights to a single color (#9526) With this optimization we can send a single UDP packet to the light rather than one packet per zone (up to 80 packets for LIFX Z). This removes a potential multi-second latency on the frontend color picker. * Update frontend * Version bump to 0.54 * Version bump to 0.55.0.dev0 * flux led lib 0.20 (#9533) * Update AbodePy to 0.11.8 (#9537) * Update requirements_all.txt * Update abode.py * Bump python_openzwave to 0.4.0.35 (#9542) * Bump python_openzwave to 0.4.0.35 * Cleanup * update usps (#9540) * update usps * fix syntax issue * Update AbodePy to 0.11.8 (#9537) * Update requirements_all.txt * Update abode.py * update usps (#9540) * update usps * fix syntax issue * Bump python_openzwave to 0.4.0.35 (#9542) * Bump python_openzwave to 0.4.0.35 * Cleanup * Bugfix Homematic hub object (#9544) * Bugfix Homematic hub object * fix hass instance * fix state unknow if 0 states * Fix a bunch of typos (#9545) s/Addres /Address / s/Chnage/Change/ s/Converion/Conversion/ s/Supressing/Suppressing/ s/agains /against / s/allready/already/ s/analagous/analogous/ s/aquired/acquired/ s/arbitray/arbitrary/ s/argment/argument/ s/aroung/around/ s/attibute/attribute/ s/auxillary/auxiliary/ s/befor /before / s/commmand/command/ s/conatin/contain/ s/conection/connection/ s/coresponding/corresponding/ s/entites/entities/ s/enviroment/environment/ s/everyhing/everything/ s/expected expected/expected/ s/explicity/explicitly/ s/formated/formatted/ s/incomming/incoming/ s/informations/information/ s/inital/initial/ s/inteface/interface/ s/interupt/interrupt/ s/mimick/mimic/ s/mulitple/multiple/ s/multible/multiple/ s/occured/occurred/ s/occuring/occurring/ s/overrided/overridden/ s/overriden/overridden/ s/platfrom/platform/ s/positon/position/ s/progess/progress/ s/recieved/received/ s/reciever/receiver/ s/recieving/receiving/ s/reponse/response/ s/representaion/representation/ s/resgister/register/ s/retrive/retrieve/ s/reuqests/requests/ s/segements/segments/ s/seperated/separated/ s/sheduled/scheduled/ s/succesfully/successfully/ s/suppport/support/ s/targetting/targeting/ s/thats/that's/ s/the the/the/ s/unkown/unknown/ s/verison/version/ s/while loggin out/while logging out/ * Catch no longer existing process in systemmonitor (#9535) * Catch no longer existing process in systemmonitor * Update log message * Again line length * Upgrade pyasn1 to 0.3.6 (#9548) * Add history_graph component (#9472) * Add support for multi-entity recent fetch of history. Add graph component * Rename graph to history_graph. Support fast fetch without current state. * Address comments * GeoRSS sensor (#9331) * new geo rss events sensor * SCAN_INTERVAL instead of DEFAULT_SCAN_INTERVAL * removed redefinition CONF_SCAN_INTERVAL * definition of self._name not required * removed unnecessary check and unnecessary parameter * changed log levels * fixed default name not used * streamlined sensor name and entity id generation, removed unnecessary parameter * fixed issue for entries without geometry data * fixed tests after code changes * simplified code * simplified code; removed unnecessary imports * fixed invalid variable name * shorter sensor name and in turn entity id * increasing test coverage for previously untested code * fixed indentation and variable usage * simplified test code * merged two similar tests * fixed an issue if no data could be fetched from external service; added test case for this case * Fixed bug with all switch devices being excluded (#9555) * fix usps? (#9557) * Added support for ARM_NIGHT for manual_mqtt alarm (#9358) * - Added support for ARM_NIGHT for manual_mqtt alarm * - port "Add post_pending_state attribute to manual alarm_control_panel #9291" to manuql_mqtt * - port "Fixed manual alarm not re-arm after 2nd trigger #9249" to manuql_mqtt * - port "Add manual alarm_control_panel pending time per state #9264" to manuql_mqtt * - Updated test_trigger_with_specific_pending to simulate real scenario e.g. arm the system then trigger * Various AirVisual bugfixes (#9554) * Various AirVisual bugfixes * Updating requirements * Added better logging for failed data retrieval * Updated Arlo cameras with new attributes (#9565) * Allow specifying multiple ports for UPNP component (#9560) * Update UPNP component * Bump dep * Fix flakiness in test * Update yeelight to 0.3.3. (#9561) Fixes basic light control in case complex transition effects were defined on a light (possibly, externally to Home Assistant): https://gitlab.com/stavros/python-yeelight/issues/17 * Add test cases and fix for device_defaults fire_event option. (#9567) * Add test cases and fix for device_defaults fire_event option. * Also for light. * Change docstring mood. * Use simplepush module, enable event, and allow encrypted communication (#9568) * Use simplepush module, enable event, and allow encrypted communication * Fix check * Add DuckDNS component (#9556) * Add DuckDNS component * Address comments * Clean up OwnTracks (#9569) * Clean up OwnTracks * Address comments * MQTT Cover: Add availability topic and configurable payloads (#9445) * MQTT Cover - Add availability_topic for online/offline status Added topic, configurable payloads, and tests. * Merge branch 'dev' into mqtt-cover-availability * Revert "Merge branch 'dev' into mqtt-cover-availability" This reverts commit 46d29794ba959e0394ff5c9904ae039a6df1d22e. * Added newline at end of test_mqtt.py * Fixed lint issue (newline at EOF) * Fixed lint issue (newline at EOF) * Updated call signature for other tests * Fixed availability message callback * Upgrade dsmr_parser to 0.11 (#9576) * Added new statistic attributes (#9433) * Added new statistic attributes Added new attributes: - Cleaning count - Total cleaning time - Total cleaning area - Time left to change main brush, side brush and filter * Code corrections Code corrections * Remove wronge hanging indentation * Added new attributes ATTR_MAIN_BRUSH_LEFT ATTR_SIDE_BRUSH_LEFT ATTR_FILTER_LEFT ATTR_CLEANING_COUNT ATTR_CLEANED_TOTAL_AREA ATTR_CLEANING_TOTAL_TIME * Remove trailing white space * Corrections of the unit test for new attributes * Hound corrections * Init self.clean_history, self.consumable_state * Hound correction * - Cleaning time and total cleaning time shown in minutes - Cleaned area and total cleaned area shown in square meters - Main brush left, side brush left, filter left time shown in hours - Display of the unit of measurement * Remove trailing white spaces * Fixed wrong continued indentation * Fixed Hound * Fixed Hound * Added new statistic attributes Added new attributes: - Cleaning count - Total cleaning time - Total cleaning area - Time left to change main brush, side brush and filter * Code corrections Code corrections * Remove wronge hanging indentation * Init self.clean_history, self.consumable_state * Hound correction * Remove UOM * Merge * Init self.clean_history, self.consumable_state * Hound correction * Init self.clean_history, self.consumable_state * Hound correction * Removed double declarations * Upgrade youtube_dl to 2017.9.24 (#9575) * Upgrade lyft_rides to 0.2 (#9578) * Increase Comed timeout since it sometimes takes a long time for the API to respond (#9536) * Increase Comed timeout since it sometimes takes a long time for the API to respond * Rewrite ComEd sensor to use asyncio * Fix whitespace and build issues * Allow customizable turn on action for LG WebOS tv (#9206) * allow customizable action for webos tv turn on as not all models allow for WOL * trying to fix the houndci-bot * last few fixes hopefully * I guess not * last time! * This is a breaking change. I have removed the build-in wake-on-lan functionality and have opted for a script which can be a wake-on-lan switch. I have also removed any reference to wol. * hoping to fix formatting * linter errors * IMAP Unread sensor updated for async and push (#9562) * IMAP Unread sensor updated for async and push * Implement renames suggested in review * Use async_timeout * Keep push capability in a variable * Reword for Hound * Replace emulated_hue: with emulated_hue_hidden: for consistency. (#9382) * Update __init__.py * fix lint errors * Update __init__.py * Update __init__.py * Update __init__.py * Update __init__.py * Update __init__.py Lint errors * use get_deprecated instead to log old attr * Updated tests to hide fan.ceiling_fan * remove space fix lint * Upgrade Sphinx to 1.6.4 (#9584) * Bump pyatv to 0.3.5 (#9586) * New Wink services. pair new device, rename, and delete, add new lock key code. Add water heater support (#9303) * Pair new device, rename, delete, and lock key code services. Also add water heater support. * Fixed tox * Fixes UPS MyChoice exception (#9587) * Fixes UPS MyChoice exception * Added unit of measurement * Collaborator-requested changes * FedEx: Adds "packages" as a unit (#9588) * Adds "packages" as a unit * Collaborator-requested changes * Cleanup entity & remove warning (#9606) * Cleanup entity & remove warning * Update comment * Add OwnTracks over HTTP (#9582) * Add OwnTracks over HTTP * Fix tests * upgrade python-ecobee-api (#9612) * Change TP-Link Switch power statistics attribute names (#9607) * Migrate Alexa smart home to registry (#9616) * Migrate Alexa smart home to registry * Fix tests * Add an input_datetime (#9313) * Initial proposal for the input_datetime * Linting * Further linting, don't define time validation twice * Make pylint *and* flake8 happy at the same time * Move todos to the PR to make lint happy * Actually validate the type of date/time * First testing * Linting * Address code review issues * Code review: Remove forgotten print()s * Make set_datetime a coroutine * Create contains_at_least_one_key_value CV method, use it * Add timestamp to the attributes * Test and fix corner case where restore data is bogus * Add FIXME * Fix date/time setting * Fix Validation * Merge date / time validation, add tests * Simplify service data validation * No default for initial state, allow 'unknown' as state * cleanup * fix schema * Cover component for RFlink (#9432) * second try on rflink / cover * no newline at end of file * changed entity * fixed comments from pvizeli * removed : * removed return 'unknown' * Fixed comments from Rytilahti * removed newline * Reverted to None * cleanup * Cleanup * Introducing support to Melnor RainCloud sprinkler systems (#9287) * Introducing support to Melnor RainCloud sprinkler systems * Make monitored_conditions optional for sub-components * Part 1/2 - Modified attributes, added DATA_ constant and using battery helper * Part 2/2 - Refactored self-update hub * Fixed change requested: - Dispatcher signal connection - Don't send raincloud object via dispatcher_send() - Honoring the dynamic scan_interval value on track_time_interval() * Inherents async_added_to_hass() on all device classes * Makes lint happy * * Refactored RainCloud code to incorporate suggestions. Many thanks to @pvizelli and @martinhjelmare!! * Removed Entity from RainCloud sensor and fixed docstrings * Update raincloud.py * Update raincloud.py * fix lint * Rewrite synology camera by using py-synology package (#9583) * - Rewrite synology camera by intruducing Api and SurveillanceStation classes to get cameras, motion settings, enable/disable motion detection, etc ... - Synology camera now shows correct state based on is_recording and is_streaming flag. Also it now supports enable / disable motion detection and show the correct motion detection status - Newly added Api and SurveillanceStation classes will be moved to a lib but it's here just for review * - Updated how payload are merged with kwargs so it works with python <3.5 * - Fixed class name conflict * - Addressed flake8 error * - Addressed pylint error * - Moved synology API related code to py-synology lib - Added py-synology==0.1.1 requirement - Removed hass from SynologyCamera constructor * - Updated requirements_all.txt * - renamed variable back to original * - Sync call to retrieve camera image should be done in camera_image() instead * - Sync call to update camera info should be done in update() instead * - Removed unused import * yeelight: allow turn_off transitions, fixes #9602 (#9605) * Move 'voltage' to const (#9621) * Yamaha MusicCast: check known_hosts (#9580) * Yamaha MusicCast: check known_hosts - pymusiccast: Version bump * Update requirements * Fixed away_mode for Ecobee thermostat. (#9559) * Fixed away_mode for Ecobee thermostat. Now away mode is properly turned on using indefinite away hold. * fixed lint warnings * fixed lint warnings * - now it is possible to use float values for ecobee temperature holds - fixed a bug that caused an exception when temperature hold was set in away mode - added unit tests for ecobee thermostat * fixed lint errors * fixed lint errors * Switched VeraSensor to use category ids (#9624) * splunk: Handle datetime objects in event payload (#9628) If an event contained a datetime.datetime object it would cause an exception in the Splunk component. Most of the media_player components do this in their `media_position_updated_at` attribute. Use the JSONEncoder from homeassistant.remote instead of just using the standard json.dumps encoder. Fixes #9590 * MQTT climate platform [continuation of #8750] (#9589) * New climate platform with MQTT * Use STATE_OFF * Basic tests for climate.mqtt * lint * actualy collect coverage * First tests and fixes * Add possibility to receive temperature via MQTT * Require only either sensor or mqtt topic * Add mqtt publishing for away mode, hold mode and aux heat. * Use configurabe on/off payloads * Add pessimistic mode * Initialize aux and away with False instead of None * Remove Sensor * Use correct scheduling method * Move all methods to coroutines * wunderground: fix supported language codes #9631 (#9633) * removed PU, added TR language code (https://www.wunderground.com/weather/api/d/docs?d=language-support&MR=1), fixes #9631 * add myself to codeowners (#9642) * raspihats: unmet dependency fix (#9638) * raspihats: update to 2.2.3 (deps fix) Raspihats platform update, upstream fixed enum34 requirements, added smbus dependency Fixes #9547 * raspihats: update to 2.2.3, smbus-cffi dependency Raspihats platform update, upstream fixed enum34 requirements, added smbus dependency Fixes #9547 * raspihats: update to 2.2.3 * raspihats: update to 2.2.3, smbus-cffi dependency * raspihats: update to 2.2.3, smbus-cffi dependency * raspihats: update to 2.2.3 (deps fix) Raspihats platform update, upstream fixed enum34 requirements, added smbus dependency Fixes #9547 * raspihats: update to 2.2.3, smbus-cffi dependency * Fixes broken source links in API docs (#9636) * Fixes broken source links in API docs * Removes illegal blank line * Move group services into their own YAML (#9597) * Move group services into their own YAML * Fix lint * Move persistent notification to package * Facebook Messenger notify component: add support for sending messages to specific page user IDs (#9643) * arlo: Add battery level sensor (#9637) * arlo: Add battery level sensor Adds a battery level sensor that monitors the battery level on Arlo cameras. * Fix lint issue * Add hysteresis attribute to threshold binary sensor (#9596) * Added hysteresis attribute to threshold binary sensor * Added threshold binary sensor hysteresis test case * Changed threshold binary sensor property name to be more self explanatory * Pulled default hysteresis value into top level declaration * Fixed linter errors * Fixed additional linter errors * Move comment to docs * Upgrade numpy to 1.13.3 (#9646) * Upgrade youtube_dl to 2017.10.01 (#9647) * Upgrade discord.py to 0.16.12 (#9648) * Upgrade netdisco to 1.2.2 (#9649) * Upgrade influxdb to 4.1.1 (#9652) * Upgrade influxdb to 4.1.1 * Upgrade influxdb to 4.1.1 * Upgrade googlemaps to 2.5.1 (#9653) * mqtt_statestream: Add options to publish attributes/timestamps (#9645) * Fixed bugs related to exception handling in pythonegardia. Updating package requirement accordingly (#9663) * Update google-api-python-client to 1.6.4 (#9658) * Bump abode to 0.11.9 (#9660) * Unit tests to improve core coverage (#9659) * Code coverage of logging util * Improve async util coverage * Add test coverage for restore_state * get_random_string test * Upgrade pyitachip2ir to 0.0.7 (#9669) * Fix typo in cancel_command description (#9671) "wasn't going to use it" * Rename input_slider to input_number and add numeric text box option (#9494) * * Rename input_slider to input_number * Update input_number to optionally display slider, input box, or both * input_number support either input box or slider mode, but not both * input_number : change service from select_value to set_value * input_number : add test for mode setting to tests * Properly handle an invalid end_time (#9675) * Support new feature to push API data to hassio (#9679) * Support new featuer to push API data to hassio * Add tests & services * Adding ignore capability to Egardia component (#9676) * Fix Google Calendar/oauth2client warning (#9677) * Fixes oauth2client warning. * Fix permission. * Implement DSMR5 support. (#9686) * Allow configuring DSMR5 protocol. * Give good example. * Using dev branch until released upstream. * Update to dsmr_parser supporting v5 arguments. * Update to latest dmsr parser, preventing exceptions thrown where warnings would suffice. * Update even more * Update requirements. * Update requirements * Add Tibber sensor (#9661) * Add Tibber sensor * remove extra space * Add recorder purge service, rework purge timer (#9523) * Add recorder purge service * Recorder test to match purge config * Removed purge timer, move service handler to setup, add service description file * Tests for recorder purge service * Recorder purge timer rework, add purge service parameter, tests * Purge service schema change * Service description change value range * First cleanup * Fix name of config * Fix restore state by filter out null value row from DB query (#9690) * Updating clicksendaudio component based on feedback (#9692) * Updating clicksendaudio component based on feedback * Updating .coveragerc - forgot to add new file clicksendaudio.py * Refactoring of onewire sensor component (#9691) * HassIO replace config changes (#9695) * Update flow * fix tests * Update hassio.py * Fixed typo in opencv (#9697) * [light.tradfri] async support with resource observation. (#7815) * [light.tradfri] Initial support for observe * Update for pytradfri 2.0 * Fix imports * Fix missing call * Don't yield from add devices * Fix imports * Minor fixes to async code. * Imports, formatting * Docker updates, some minor async code changes. * Lint * Lint * Update pytradfri * Minor updates for release version * Build fixes * Retry observation if failed * Revert * Additional logging, fix returns * Fix rename * Bump version * Bump version * Support transitions * Lint * Fix transitions * Update Dockerfile * Set temp first * Observation error handling * Lint * Lint * Lint * Merge upstream changes * Fix bugs * Fix bugs * Fix bugs * Lint * Add sensor * Add sensor * Move sensor attrs * Filter devices better * Lint * Address comments * Pin aiocoap * Fix bug if no devices * Requirements * RFC: Create a secrets file and enable HTTP password by default (#9685) * Create a secret and enable password by default * Comment out api password secret * Lint/fix tests * Support for The Things Network (#9627) * Support for The Things network's Data Storage * Rename platform and other changes (async and dict) * Rename sensor platform and remove check for 200 * Update frontend * fixed duplicate words (#9705) * move icon battery function from util to helpers (#9708) * Fix coap commit (#9712) * Deprecate Python 3.4 support (#9684) * Deprecate Python 3.4 support * Update text * Updating helper's icon_for_battery_level location (#9594) * Upgrade pyhomematic, add path setting and HM-CC-VG-1 support (#9707) * Bump pyhomematic, add path setting, HM-CC-VG-1 support * Added requirement * bump the version and catch all exceptions to avoid showing backtraces… (#9720) * bump the version and catch all exceptions to avoid showing backtraces but a more sane error message * catch only BTLEExceptions, fix logging strings * More netdata sensors (#9719) * Added more netdata sensors * Changed precision on counts, packets, and uptime * Upgrade pysnmp to 4.3.10 (#9722) * arlo: Add alarm control panel component (#9711) * arlo: Add alarm control panel component Allows importing arlo base stations as an alarm control panel component in HA. Lets the users configure a custom home mode since arlo does not have a built-in home mode. * fix lint and houndci comments * Use async_update to update the state Move the state updating code from state() to update() since it does I/O. * Do not set state in __init__ Make sure that update is called by passing the second parameter to async_add_devices. * Order imports and fix dos-strings * Abode Temp, Humidity, and Light Sensor (#9709) * Update to 0.12.1 and sensor implementation. * Removing unnecessary dict gets. * Added name property to actually use the _name variable. * Update docstring * Arlo clean-up (#9725) * Fix remaining isses from #9711 * More clean-up * Introducing support to Travis-CI (#9701) * Introduced support to Travis CI * Added Last Build Started sensor and simplified code * Fixed logic error * Simplified _LOGGER.debug statement * Introduced support to Travis CI * Added Last Build Started sensor and simplified code * Fixed logic error * Simplified _LOGGER.debug statement * Renamed parameter since the repository_names expects a list * Refactoring code to synchronous * Simplified variables names * Add show_on_map config option to AirVisual (#9654) * Removed lat/long attributes * Linting * Revised PR to focus on show_on_map configuration * Move 'show_on_map' to const (#9727) * Bump release to 0.56.0dev (#9726) * [light.tradfri] Clone all of aiocoap to ensure pinned commit will be present (#9713) * Add andrey-git to codeowners (#9718) * Fix: Last Played Media Title persists in plex (#9664) * Fix: Last Played Media Title in plex would stay even when player was idle/off Primary Fix is in the "if self._device" portion. code in "if self._session" is a catch all but i'm not 100% if it is needed. * Fixed lint issues with previous commit * 1st Pass at refactoring plex refresh Moved _media** into clearMedia() which is called in _init_ and at start of refresh. Removed redunant _media_* = None entries Grouped TV Show and Music under single if rather than testing seperately for now. * Fixed invalid name for _clearMedia() Removed another media_* = None entry * Removed print() statements used for debug * Removed unneeded "if" statement * Event trigger nested conditions (#9732) * Test to supported nested event triggers * Update event trigger to allow nested data tests * Rewrite Alexa Smart-Home skill to v3 (#9699) * Rewrite Alexa Smart-Home skill to v3 * add discovery & fix brigness * Rewrite Tests * fix lint * fix lint p2 * fix version * fix tests * fix test message generator * Update smart_home.py * fix test * fix set bug * fix list * fix response name for discovery * fix flucky tests * Fix I/O in event loop by Arlo alarm control panel (#9738) * Make Arlo battery_level icon dynamic (#9747) * Make Arlo battery_level icon dynamic * makes lint happy * Fix for TypeError in synology camera (#9754) * switch.tplink, light.tplink: bump the pyhs100 version and adapt to api changes (#9454) * bump the pyhs100 version and fix api changes * switch.tplink: avoid I/O during __init__ * initialize _name to None in __init__ * update requirements_all.txt for the new version * RFC: Use bind_hass for helpers (#9745) * Add Helpers bind_hass functionality * Update other helpers * Skybell (#9681) * New Skybell platform with components * Added skybell components to omit. * Preemptively fixing lint issues (hopefully). * Removed unused variable. * Requested changes. * Additional CRs * Hopefully the last of the CR's! * add myself to yeelight owners, too (#9759) * Update CODEOWNERS (#9760) * Adding my contributions (#9761) * Initializing statistics sensor with data from database (#9753) * Initializing statistics sensor with data from database * fixed broken test case * usage of recorder component is now optional, thx to @andrey-git * added test case for initialize_from_database * Match test requirements by full package name. (#9764) * yeelight: implement min_mireds and max_mireds, fixes #9509 (#9763) * yeelight: implement min_mireds and max_mireds, fixes #9509 thanks to @amelchio for pointing this out! * remove typing infos * Bump raincloudy version 0.0.3 (#9767) * Bump raincloudy version 0.0.3 * Fix logic for raincloudy status binary_sensor * Simplified binary_sensor logic * Simplify * Xiaomi Smart WiFi Socket and Smart Power Strip integration (#9138) * Xiaomi Smart WiFi Socket and Smart Power Strip integration * Comment updated. * Blank line removed. * Typo fixed. * Version of python-mirobo bumped. * Version of python-mirobo bumped: Lightweight API changes. * Additional API changes. * Library version properly pinned again. * Platform not ready behavior fixed. Expose the device model as sensor attribute. Device initialized log message added. Provides device model, firmware and hardware version. * Component renamed: switch.xiaomi_plug -> switch.xiaomi_miio * Revise based on review: Unused code removed. Filename updated. * fix for LocationParseError in netgear platform (#9683) * fix for LocationParseError in netgear platform * added unit tests for get_scanner() * fixed houndci-bot warnings * fixed lint warnings * fixed lint warnings * fixed broken test * removed guard clause from netgear.py removed all discovery related code from device_tracker removed unnecessary unit test * removed discovery related tests * removed unused import * removed unused import * Expose time module in Python Scripts (#9736) * Expose time module in Python Scripts * Make dt_util available in Python Scripts * Limit methods in time module * Add time.mktime * Limit access to datetime * Add warning to time.sleep * Lint * Add notification platform for Rocket.Chat. (#9553) * Add notification platform for Rocket.Chat. * Changes to Rocket.Chat notification platform based on feedback. * Implement better error handling for Rocket.Chat platform. * Return None if Rocket.Chat notify platform init fails. * Refactor Rocket.Chat notifications. Refactor Rocket.Chat notification platform to remove async and simplify error handling. * fix url * Updating pythonegardia package requirement to .22 because of fixed bug in passing default value for parameter SSL for egardiaserver (#9770) * Adding myself as codeowner for egardia alarm control panel. (#9772) Adding jeroenterheerdt as codeowner for egardia alarm control panel. * WIP: Fix Arlo Camera blocking IO (#9758) * WIP: Fix Arlo Camera blocking IO * Accidental undo * Linting issues * Owner-requested changes * Bumped pyarlo version and added Throttle * Fix * Update requirements_all.txt * Tesla bug fixes. (#9774) * Tesla bug fixes. * Added myself to CODEOWNERS for tesla. * Fix off by one error in arwn platform (#9781) There is an off by one error that causes period exceptions. Fix this. * missing is_closed ( rflink cover fix ) (#9776) * Added is_closed * whitespaces -- * removed whitespace * Wink dome siren support (#9667) * Support for Wink Dome siren/chimes * Bump rxv library to 0.5.1 (#9784) This fixes some bugs with interfacing with yamaha receivers, including closing bug #5209. * Communication timeout support in modbus hub. (#9780) * Communication timeout support in modbus hub. Timeout parameter are taken from configuration and passed to pymodbus constructor. * CONF_TYPE and CONF_TIMEOUT imported from const.py * [light.tradfri] Fix transition time (#9785) * Fix transition time, set a default * Wrong default * Use int for safety * Revert default. * OwnTracks: Fix handler is None checking (#9794) * OwnTracks: Fix handler is None checking * Update owntracks.py * Simplify track_same_state (#9795) * Optimize event matcher (#9798) * Optimize event matcher * Tweak order of checks * Add a benchmark for time_changed helper * Add state change benchmark * fix lint * Resolving bug that prevents ssl_verify option for Unifi device_tracker (#9788) * Added TODO to illustrate my intentions * Resolved linting issue * Resolved bool or file validation and updated tests The tests have been updated to include mocks to assert a temp ca cert exists as it should for the positive tests with an additional negative test for a file not existing being tested. * Resolved flake8 linting issues (test docstrings) * Upgrade pyasn1 to 0.3.7 and pyasn1-modules to 0.1.5 (#9810) * Split map panel out into its own component (#9814) * Restore home-assistant-polymer pointer from #9720 (#9825) * Fix ISY994 fan platform overwriting state property (#9817) * ISY994 platform overwrote state * Update isy994.py * Update isy994.py * Wait_template - support for 'trigger.entity_id' and data_template values (#9807) * *Added support for use of 'trigger.entity_id' and service->data_template->script in wait_template * * Fixed style violations * * Fixed regular expression (_RE_GET_POSSIBLE_ENTITIES) * * combined 'extract_entities' and 'extract_entities_with_variables' * fixed regular expression * * Added first test for extract_entities_with_variables * * Added Unittests (tests/helpers/test_template.py test_extract_entities_with_variables) * * Added Unittests (tests/helpers/test_script.py test_wait_template_variables) * * Added Unittests (tests/components/automation/test_template.py test_wait_template_with_trigger) * * Added Unittests (tests/components/automation/test_state.py test_wait_template_with_trigger) * * Added Unittests (tests/components/automation/test_numeric_state.py test_wait_template_with_trigger) * * Fixed style violations * * Fixed style violations * * Fixed style violations * * Fixed style violations * * Fixed style violations * * Fixed style violations * * Updated regular expression and delete whitespaces * Adds image attribute to html5 notify (#9832) (#9835) * OwnTracks: Handle lwt message (#9831) * OwnTracks: Handle lwt message * Update owntracks.py * Upgrade libnacl (#9769) * Upgrade libnacl to 1.6.0 * Small style updates * Fixed reporting of vera UV sensors (#9838) * Update CODEOWNERS */axis.py (#9823) Add code owner for */axis.py * fix climate services (missing indentation, wrongly formatted example) (#9805) * Run initial generation for development mode (#9826) * Run initial generation for development mode * Use yarn dev * Add service descriptions (#9806) * Added descriptions for services under homeassistant domain * lint fixes * Fixing file permissions * Bugfix: Include MQTT schema (#9802) * Bugfix/9811 jinja autoescape (#9842) * Added autoescape kwarg to Jinja environment * Removed extra comma * Changed yaml.load into yaml.safe_load (#9841) * New PR (#9787) * Do not auto-install credstash (#9844) * Add namecheap DNS component (#9821) * Add namecheap DNS component * Updates for pull-request * remove unused import in test file * Update .coveragerc * Remove namecheap dns service (#9845) * Use the Last Seen attribute in unify (#8998) * Uses the Last Seen attribute in unify * Update unifi.py fix format * Update unifi.py formatting again * update test_unifi to call CONF_CONSIDER_HOME Updated. * Update test_unifi.py * Update test_unifi.py * More unit test test * Update where consider_home comes from. * Update test_unifi.py * Update unifi.py * Update unifi.py * Update test_unifi.py * Update unifi.py * Update unifi.py * Update test_unifi.py * fix hound * Update test_unifi.py * Update test_unifi.py * Update unifi.py * Update unifi.py * Update test_unifi.py * Update unifi.py * Update unifi.py * Update test_unifi.py * Update unifi.py * Update test_unifi.py * Update test_unifi.py * Update test_unifi.py * Update unifi.py * Update unifi.py * Update unifi.py * Update unifi.py * Update test_unifi.py Fix the butcher of tests. * Update unifi.py * Update test_unifi.py * Update test_unifi.py * Update unifi.py * Update unifi.py * Update test_unifi.py * Update test_unifi.py * Update test_unifi.py * Update test_unifi.py * Update test_unifi.py * Update test_unifi.py * Update test_unifi.py * Update test_unifi.py * Update test_unifi.py * Update unifi.py * Update test_unifi.py * Update unifi.py * Update unifi.py * Update unifi.py * Update unifi.py * Add CAPSman master to mikrotik presence detection (#9729) * Add CAPSman master to mikrotik presence detection Automatically prefer caps-man registered clients over locally connected * Remove blank line * Trailing whitespace removed * File permissions fix (#9847) * Fixing file permissions * Fixing file permissions * HassIO - TimeZone / Host services (#9846) * HassIO - TimeZone / Host services * Update hassio.py * Update test_hassio.py * Changing name of clicksendaudio component to clicksend_tts (#9859) * Upgrade youtube_dl to 2017.10.12 (#9862) * Uptime sensor (#9856) * Added uptime sensor for homeassistant * Fixed pylint and flake8 errors * Made requested changes from PR - Fixed stale docstrings - Changed default state to None - Added ability for user to use hours or days * Fixed typo * Added unit_of_measurement check to test * Converted to async - Changed tests to work with async * Minor updates * Darksky enhancements (#9851) * Correct capitalization inconsistency in DarkSky All two-word sensors ("Precip Intensity," "Nearest Storm Bearing," etc) in Darksky uses title case for the friendly name of the sensor, with the exception of "Dew point." * Implement UV Index in Darksky * Fixed whitespace for Tox compliance * Add unit for UV Index. Per recommendation of reviewer, added 'UV Index' as a CONST in const.py, then used that const in both DarkSky and ISY994. It looks like BloomSky might also support UV Index and it should probably be standardized. * Upgrade psutil to 5.4.0 (#9869) * minimal fixes in the owntracks mqtt device tracker (#9866) * fix UnboundLocalError when unable to parse payload, and show bad topics that cannot be parsed ok * Update owntracks.py * Cloud connection via aiohttp (#9860) * Cloud: connect to cloud * Fix tests in py34 * Update warrant to 0.5.0 * Differentiate errors between unknown handler vs exception * Lint * Respond to cloud message to logout * Refresh token exception handling * Swap out bare exception for RuntimeError * Add more tests * Fix tests py34 * handle OWM API error calls (#9865) * Upgrade paho-mqtt to 1.3.1 (#9874) * Fix #9839 (#9880) * Fix #9839 * Update requirements * Default state: STATE_UNKNOWN -> None * Default the state to None in the constructor as well * Upgrade python-telegram-bot to 8.1.1 (#9882) * update python-telegram-bot to v8.1.1 * update python-telegram-bot to v8.1.1 * Xknx improvements (#9871) * Issue https://github.com/XKNX/xknx/issues/65 Make state_updater adjustable by config file (On/OFF) * Issue https://github.com/XKNX/xknx/issues/48 updated home assistant plugin: added support for setpoint shift * bumped version * added missing docstrings. * Bumped version. * Fixed requirements_all.txt * added new options to PLATFORM_SCHEMA * zha: Update to bellows 0.4.0 (#9890) Fixes: #8822 * Changing clicksendaudio to clicksend_tts in .coveragerc (#9900) * Added super attributes to Wink binary sensors (#9824) * Added super attributes to Wink binary sensors * Removed unused import. * Dependemcy version bump. (#9899) Closes #8213. Closes #7575. * Update osramlightify.py (#9905) * Fixes (#9912) * Fix load of components without any config from packages (#9901) * Fix load of components without any config from packages - Add 'None' to the packages config schema validation, to be able to load components without any more configuration from yaml package files, like `wake_on_lan`, `media_extractor` and so on. * test the ability to load components without configuration from packages * Fixes (#9911) * add last_action for xiaomi cube (#9897) * Added support for Denon AVR-4810. (#9887) * Recorder: Extra check to incoming connections which could be not sqlite3 ones (#9867) * Extra check to incoming connections The incoming connection could be other than self.db_url, because some 'custom_component' could be making these, and then, if they're not sqlite3 connections, an error will raise because those haven't the `dbapi_connection.isolation_level` attrib. * lint fix * simplify check: isinstance test only * Fix the resource naming in the UI (#9916) Use proper English for the UI representation without breaking the component. * Update xiaomi_aqara.py (#9920) * Fix the resource naming in the UI (#9927) Use proper English for the UI representation without breaking the component. * Add transmission sensor: number of active torrents (#9914) * Add transmission sensor: number of active torrents * Make variable name shorter * Google Actions for Assistant (#9632) * http: Add headers key to json[_message] * Add google_assistant component This component provides API endpoints for the Actions on Google Smart Home API to interact with Google Assistant. * google_assistant: Re-add fan support * google_assistant: Fix Scene handling - The way I originally wrote the MAPPING_COMPONENT and the way it's actual used changed so the comment was updated to match that. - Use const's in more places - Handle the ActivateScene command correctly * google_assistant: Fix flakey compare test Was failing on 3.4.2 and 3.5, this is more correct anyway. * google_assistant: Use volume attr for media_player * Allow flexible relayer url (#9939) * update async_timeout from v1.4.0 tp v2.0.0 (#9938) * Bump py-synology to 0.1.5 (#9932) * Update aioimaplib from v0.7.12 to v0.7.13 (#9930) * Update aioimaplib from v0.7.12 to v0.7.13 Changelog v0.7.13: [aiolib] adds a connection lost callback [test] imapserver : added APPENDUID response for APPEND cmd [test][fix] imapserver append should add to the connected user mb [test] imapserver : more accurate building of message headers (using python email module) * run script/gen_requirements_all.py * A new platform for controlling Monoprice 6-Zone amplifier (#9662) * added implementation for monoprice 6-zone amplifier. This implementation is based on and very similar to russoun_rnet implementaion * updated comments and cleaned up code * updated comments and cleaned up code * added unit tests * removed 'name' attribute from platform schema. * added monoprice.py to .coveragerc * fixed lint * fixed lint errors * fixed lint errors * added monoprice to requirements_all.txt * fixed lint errors again * implemented change requests * fixed lint error * added exception handling to setup_platform() * replaced catchall with SerialException only * added myself to CODEOWNERS * fixed weird merge to CODEOWNERS * Align away state tag with device_trackers (#9884) * Add serial sensor (#9861) * Add serial sensor * Rename config variable and cancel * Fix missing timeout for Netatmo binary sensor (#9850) * Fix missing timeout for Netatmo binary sensor This fix also merges timeout and offset because there were the same thing Signed-off-by: Hugo D. (jabesq) * Fix lint errors * Fix style * Xiaomi config validation (#9941) * validate xiaomi config * Update xiaomi_aqara.py * check for valid config * use consts * using defusedxml ElementTree for safer parsing of untrusted XML data (#9934) * using defusexml ElementTree for safer parsing of untrusted XML data * move from core dependency to platform specific dependency * style difference: put back end of list comma in setup.py * notify.xmpp - Add support for MUC (#9931) * Add support for MUC * Fix two spaces before inline comment * FFmpeg 1.8 (#9944) * Update requirements_all.txt * Update requirements_test_all.txt * Update ffmpeg.py * Update ffmpeg.py * Update yi.py * Update onvif.py * Update yi.py * Changed returned attribute from "Game" to "game" (#9945) I noticed the steam component "game" attribute is capitalized. This should be lowercase if I'm not mistaken. From: return {'Game': self._game} To: return {'game': self._game} Not sure if i'm doing this correctly... apologizes if I'm not! * Move 'lights' to const.py (#9929) * Update directpy to 0.2 (#9948) * Update enocean to 0.40 (#9949) * Update hikvision to 1.2 (#9953) * Update fritzhome to 1.0.3 (#9951) * Update fritzconnection to 0.6.5 (#9950) * Upgraded pyhomematic (#9956) * Add emeter attributes (#9903) * Add emeter attributes. * Remove unused attributes. * Rework supported features so it only queries the bulb once. * Used cached supported_features, catch errors if energy usage not reported. * Use default clientsession to stream synology video (#9959) * Update requirements_test_all.txt * Update ffmpeg.py * Update ffmpeg 1.9 (#9963) * Improve SSL certs used by aiohttp (#9958) * Improve SSL certs used by aiohttp * Add certifi package * Lint * Fix async probs (#9924) * Update entity.py * Update entity_component.py * Update entity_component.py * Update __init__.py * Update entity_component.py * Update entity_component.py * Update entity.py * cleanup entity * Update entity_component.py * Update entity_component.py * Fix names & comments / fix tests * Revert deadlock protection * Add tests for entity * Add test fix name * Update other code * Fix lint * Remove restore state from template entities * Lint * Add Toon support (#9483) * Added Toon support again * Forgot about .coveragerc * Fixed style issues * More styling and importing fixes * Implemented the suggestions made by @pvizeli * The smallest fix possible * Removed custom names for Toon states * Fix last push with 2 outdated lines * Removed HOME and NOT_HOME, moved to just climate states * Bumped dependency for better handling of smartplugs that don't report power consumption * Implemented changes as suggested by @balloob * Rebase, gen_requirements_all.py finally working --- .coveragerc | 45 +- CODEOWNERS | 25 + Dockerfile | 4 +- docs/source/conf.py | 6 +- homeassistant/__main__.py | 11 +- homeassistant/bootstrap.py | 56 +- homeassistant/components/__init__.py | 27 +- homeassistant/components/abode.py | 284 +++++++- .../alarm_control_panel/__init__.py | 11 +- .../components/alarm_control_panel/abode.py | 21 +- .../components/alarm_control_panel/arlo.py | 121 ++++ .../alarm_control_panel/concord232.py | 2 +- .../components/alarm_control_panel/demo.py | 18 +- .../components/alarm_control_panel/egardia.py | 15 +- .../components/alarm_control_panel/manual.py | 105 +-- .../alarm_control_panel/manual_mqtt.py | 126 +++- .../alarm_control_panel/satel_integra.py | 94 +++ .../components/alarm_control_panel/spc.py | 14 +- homeassistant/components/alexa/smart_home.py | 203 ++++++ homeassistant/components/api.py | 7 +- homeassistant/components/apple_tv.py | 2 +- homeassistant/components/arlo.py | 6 +- homeassistant/components/automation/event.py | 26 +- .../components/automation/numeric_state.py | 4 +- homeassistant/components/automation/state.py | 4 +- homeassistant/components/axis.py | 66 +- .../components/binary_sensor/abode.py | 61 +- .../components/binary_sensor/doorbird.py | 60 ++ .../components/binary_sensor/insteon_plm.py | 4 +- homeassistant/components/binary_sensor/iss.py | 5 +- homeassistant/components/binary_sensor/knx.py | 14 +- .../components/binary_sensor/mqtt.py | 56 +- .../components/binary_sensor/netatmo.py | 39 +- .../components/binary_sensor/raincloud.py | 72 +++ .../components/binary_sensor/satel_integra.py | 90 +++ .../components/binary_sensor/skybell.py | 97 +++ homeassistant/components/binary_sensor/spc.py | 4 +- .../components/binary_sensor/template.py | 13 +- .../components/binary_sensor/tesla.py | 1 - .../components/binary_sensor/threshold.py | 34 +- .../components/binary_sensor/wink.py | 43 +- .../{xiaomi.py => xiaomi_aqara.py} | 19 +- homeassistant/components/calendar/__init__.py | 1 + .../components/calendar/services.yaml | 19 + homeassistant/components/calendar/todoist.py | 544 ++++++++++++++++ homeassistant/components/camera/__init__.py | 11 +- homeassistant/components/camera/abode.py | 101 +++ homeassistant/components/camera/amcrest.py | 2 +- homeassistant/components/camera/arlo.py | 70 +- homeassistant/components/camera/axis.py | 26 +- homeassistant/components/camera/blink.py | 2 +- homeassistant/components/camera/doorbird.py | 90 +++ homeassistant/components/camera/ffmpeg.py | 4 +- homeassistant/components/camera/foscam.py | 2 +- homeassistant/components/camera/onvif.py | 4 +- homeassistant/components/camera/skybell.py | 67 ++ homeassistant/components/camera/synology.py | 236 ++----- homeassistant/components/camera/usps.py | 2 +- homeassistant/components/camera/uvc.py | 2 +- homeassistant/components/camera/yi.py | 137 ++++ homeassistant/components/climate/__init__.py | 94 ++- homeassistant/components/climate/demo.py | 6 +- homeassistant/components/climate/ecobee.py | 55 +- .../components/climate/eq3btsmart.py | 8 +- homeassistant/components/climate/knx.py | 48 +- homeassistant/components/climate/mqtt.py | 485 ++++++++++++++ .../components/climate/services.yaml | 8 +- homeassistant/components/climate/tesla.py | 1 - homeassistant/components/climate/toon.py | 95 +++ homeassistant/components/climate/wink.py | 255 +++++--- homeassistant/components/cloud/__init__.py | 134 +++- homeassistant/components/cloud/auth_api.py | 219 ++----- homeassistant/components/cloud/const.py | 16 +- homeassistant/components/cloud/http_api.py | 35 +- homeassistant/components/cloud/iot.py | 194 ++++++ homeassistant/components/cloud/util.py | 10 - homeassistant/components/cover/__init__.py | 13 +- homeassistant/components/cover/abode.py | 27 +- homeassistant/components/cover/knx.py | 58 +- homeassistant/components/cover/mqtt.py | 54 +- homeassistant/components/cover/rflink.py | 121 ++++ homeassistant/components/cover/template.py | 7 +- .../cover/{xiaomi.py => xiaomi_aqara.py} | 3 +- homeassistant/components/demo.py | 6 +- .../components/device_tracker/__init__.py | 23 +- .../components/device_tracker/aruba.py | 4 +- .../components/device_tracker/automatic.py | 2 +- .../components/device_tracker/fritz.py | 2 +- .../components/device_tracker/icloud.py | 2 +- .../device_tracker/keenetic_ndms2.py | 121 ++++ .../components/device_tracker/mikrotik.py | 21 +- .../components/device_tracker/owntracks.py | 611 ++++++++++-------- .../device_tracker/owntracks_http.py | 54 ++ .../components/device_tracker/snmp.py | 6 +- .../components/device_tracker/unifi.py | 23 +- .../components/device_tracker/upc_connect.py | 5 +- .../components/device_tracker/xiaomi.py | 2 +- homeassistant/components/discovery.py | 4 +- homeassistant/components/doorbird.py | 44 ++ homeassistant/components/downloader.py | 2 +- homeassistant/components/duckdns.py | 102 +++ homeassistant/components/ecobee.py | 2 +- .../components/emulated_hue/__init__.py | 20 +- homeassistant/components/emulated_hue/upnp.py | 8 +- homeassistant/components/enocean.py | 2 +- homeassistant/components/fan/__init__.py | 12 +- homeassistant/components/fan/insteon_local.py | 2 +- homeassistant/components/fan/isy994.py | 20 +- homeassistant/components/fan/mqtt.py | 3 + homeassistant/components/ffmpeg.py | 2 +- homeassistant/components/frontend/__init__.py | 3 +- .../components/frontend/templates/index.html | 20 +- homeassistant/components/frontend/version.py | 6 +- .../frontend/www_static/frontend.html | 25 +- .../frontend/www_static/frontend.html.gz | Bin 168127 -> 172521 bytes .../www_static/home-assistant-polymer | 2 +- .../components/frontend/www_static/mdi.html | 2 +- .../frontend/www_static/mdi.html.gz | Bin 208182 -> 211310 bytes .../www_static/panels/ha-panel-config.html | 4 +- .../www_static/panels/ha-panel-config.html.gz | Bin 34595 -> 35106 bytes .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 5139 -> 5136 bytes .../frontend/www_static/webcomponents-lite.js | 337 +++++----- .../www_static/webcomponents-lite.js.gz | Bin 25865 -> 26556 bytes homeassistant/components/google.py | 12 +- .../components/google_assistant/__init__.py | 52 ++ .../components/google_assistant/auth.py | 86 +++ .../components/google_assistant/const.py | 37 ++ .../components/google_assistant/http.py | 180 ++++++ .../components/google_assistant/smart_home.py | 161 +++++ .../{group.py => group/__init__.py} | 8 +- homeassistant/components/group/services.yaml | 59 ++ homeassistant/components/hassio.py | 117 +++- homeassistant/components/history.py | 50 +- homeassistant/components/history_graph.py | 87 +++ homeassistant/components/homematic.py | 43 +- homeassistant/components/http/__init__.py | 10 +- homeassistant/components/http/auth.py | 26 + .../components/image_processing/opencv.py | 4 +- .../image_processing/seven_segments.py | 2 +- homeassistant/components/influxdb.py | 2 +- homeassistant/components/input_boolean.py | 19 +- homeassistant/components/input_datetime.py | 227 +++++++ .../{input_slider.py => input_number.py} | 63 +- homeassistant/components/ios.py | 2 +- homeassistant/components/knx.py | 10 +- homeassistant/components/light/__init__.py | 12 +- homeassistant/components/light/abode.py | 84 +++ homeassistant/components/light/flux_led.py | 2 +- homeassistant/components/light/hue.py | 4 +- .../components/light/insteon_local.py | 2 +- homeassistant/components/light/insteon_plm.py | 4 +- homeassistant/components/light/knx.py | 14 +- homeassistant/components/light/lifx.py | 23 +- homeassistant/components/light/mqtt_json.py | 2 +- .../components/light/osramlightify.py | 2 +- homeassistant/components/light/rflink.py | 4 +- homeassistant/components/light/skybell.py | 87 +++ homeassistant/components/light/tellstick.py | 2 +- homeassistant/components/light/template.py | 20 +- homeassistant/components/light/tplink.py | 71 +- homeassistant/components/light/tradfri.py | 240 +++++-- .../light/{xiaomi.py => xiaomi_aqara.py} | 3 +- ...{xiaomi_philipslight.py => xiaomi_miio.py} | 18 +- homeassistant/components/light/yeelight.py | 25 +- homeassistant/components/lock/__init__.py | 12 +- homeassistant/components/lock/abode.py | 21 +- homeassistant/components/lock/services.yaml | 25 +- homeassistant/components/lock/tesla.py | 3 - homeassistant/components/lock/wink.py | 35 +- homeassistant/components/map.py | 18 + homeassistant/components/media_extractor.py | 2 +- .../components/media_player/__init__.py | 13 +- .../components/media_player/apple_tv.py | 36 +- homeassistant/components/media_player/cast.py | 2 +- .../components/media_player/denon.py | 2 +- .../components/media_player/denonavr.py | 2 +- .../components/media_player/directv.py | 6 +- .../components/media_player/dunehd.py | 2 +- .../components/media_player/liveboxplaytv.py | 8 +- .../components/media_player/monoprice.py | 185 ++++++ homeassistant/components/media_player/mpd.py | 44 +- .../components/media_player/openhome.py | 2 +- .../components/media_player/philips_js.py | 4 +- homeassistant/components/media_player/plex.py | 79 +-- .../components/media_player/russound_rio.py | 2 +- .../components/media_player/russound_rnet.py | 2 +- .../components/media_player/services.yaml | 20 +- .../components/media_player/sonos.py | 6 +- .../components/media_player/universal.py | 6 +- homeassistant/components/media_player/vlc.py | 2 +- .../components/media_player/webostv.py | 43 +- .../components/media_player/yamaha.py | 4 +- .../media_player/yamaha_musiccast.py | 44 +- homeassistant/components/modbus.py | 14 +- homeassistant/components/mqtt/__init__.py | 2 +- homeassistant/components/mqtt/discovery.py | 3 +- homeassistant/components/mqtt_statestream.py | 33 +- homeassistant/components/namecheapdns.py | 70 ++ homeassistant/components/notify/apns.py | 2 +- .../components/notify/clicksend_tts.py | 90 +++ homeassistant/components/notify/discord.py | 2 +- homeassistant/components/notify/facebook.py | 9 +- homeassistant/components/notify/html5.py | 4 +- homeassistant/components/notify/kodi.py | 2 +- homeassistant/components/notify/rocketchat.py | 76 +++ homeassistant/components/notify/simplepush.py | 49 +- homeassistant/components/notify/twitter.py | 4 +- homeassistant/components/notify/xmpp.py | 28 +- .../__init__.py} | 4 +- .../persistent_notification/services.yaml | 23 + homeassistant/components/python_script.py | 75 ++- homeassistant/components/raincloud.py | 179 +++++ homeassistant/components/raspihats.py | 3 +- homeassistant/components/recorder/__init__.py | 77 ++- .../components/recorder/migration.py | 4 +- homeassistant/components/recorder/purge.py | 3 +- .../components/recorder/services.yaml | 9 + homeassistant/components/remote/__init__.py | 11 +- homeassistant/components/remote/itach.py | 2 +- homeassistant/components/rflink.py | 15 + homeassistant/components/satel_integra.py | 152 +++++ homeassistant/components/sensor/abode.py | 81 +++ homeassistant/components/sensor/airvisual.py | 244 ++++--- .../components/sensor/android_ip_webcam.py | 12 +- homeassistant/components/sensor/arlo.py | 42 +- homeassistant/components/sensor/arwn.py | 2 +- homeassistant/components/sensor/bitcoin.py | 2 +- homeassistant/components/sensor/citybikes.py | 8 +- .../components/sensor/coinmarketcap.py | 2 +- .../components/sensor/comed_hourly_pricing.py | 56 +- homeassistant/components/sensor/darksky.py | 10 +- homeassistant/components/sensor/dsmr.py | 57 +- .../components/sensor/dte_energy_bridge.py | 28 +- homeassistant/components/sensor/envirophat.py | 2 +- homeassistant/components/sensor/fedex.py | 5 + homeassistant/components/sensor/fitbit.py | 13 +- .../components/sensor/fritzbox_callmonitor.py | 2 +- .../components/sensor/fritzbox_netmonitor.py | 2 +- .../components/sensor/geo_rss_events.py | 243 +++++++ homeassistant/components/sensor/glances.py | 20 +- .../components/sensor/google_travel_time.py | 2 +- homeassistant/components/sensor/homematic.py | 1 + homeassistant/components/sensor/imap.py | 155 +++-- homeassistant/components/sensor/influxdb.py | 3 +- homeassistant/components/sensor/ios.py | 2 +- homeassistant/components/sensor/isy994.py | 4 +- homeassistant/components/sensor/knx.py | 14 +- homeassistant/components/sensor/lyft.py | 2 +- homeassistant/components/sensor/modbus.py | 2 +- homeassistant/components/sensor/mqtt_room.py | 8 +- homeassistant/components/sensor/netdata.py | 12 + homeassistant/components/sensor/onewire.py | 148 +++-- .../components/sensor/openweathermap.py | 18 +- homeassistant/components/sensor/pvoutput.py | 4 +- homeassistant/components/sensor/raincloud.py | 69 ++ homeassistant/components/sensor/ring.py | 4 + homeassistant/components/sensor/serial.py | 90 +++ homeassistant/components/sensor/skybell.py | 82 +++ homeassistant/components/sensor/snmp.py | 2 +- homeassistant/components/sensor/statistics.py | 48 +- .../components/sensor/steam_online.py | 2 +- .../sensor/swiss_public_transport.py | 141 ++-- .../components/sensor/systemmonitor.py | 51 +- homeassistant/components/sensor/template.py | 5 - homeassistant/components/sensor/tesla.py | 1 - .../components/sensor/thethingsnetwork.py | 163 +++++ homeassistant/components/sensor/tibber.py | 99 +++ homeassistant/components/sensor/toon.py | 256 ++++++++ homeassistant/components/sensor/tradfri.py | 116 ++++ .../components/sensor/transmission.py | 25 +- homeassistant/components/sensor/travisci.py | 168 +++++ homeassistant/components/sensor/uber.py | 2 +- homeassistant/components/sensor/ups.py | 23 +- homeassistant/components/sensor/uptime.py | 78 +++ homeassistant/components/sensor/usps.py | 6 +- homeassistant/components/sensor/vera.py | 26 +- .../components/sensor/worxlandroid.py | 163 +++++ .../components/sensor/wunderground.py | 10 +- .../sensor/{xiaomi.py => xiaomi_aqara.py} | 9 +- homeassistant/components/sensor/zha.py | 2 +- homeassistant/components/services.yaml | 219 ++++--- homeassistant/components/shiftr.py | 2 +- homeassistant/components/skybell.py | 93 +++ homeassistant/components/sleepiq.py | 2 +- homeassistant/components/splunk.py | 4 +- homeassistant/components/switch/__init__.py | 11 +- homeassistant/components/switch/abode.py | 53 +- .../components/switch/acer_projector.py | 2 +- .../components/switch/android_ip_webcam.py | 2 +- homeassistant/components/switch/broadlink.py | 116 +++- homeassistant/components/switch/doorbird.py | 97 +++ homeassistant/components/switch/flux.py | 3 +- homeassistant/components/switch/fritzdect.py | 2 +- .../components/switch/hikvisioncam.py | 2 +- .../components/switch/insteon_local.py | 2 +- .../components/switch/insteon_plm.py | 4 +- homeassistant/components/switch/knx.py | 14 +- homeassistant/components/switch/raincloud.py | 94 +++ .../components/switch/rainmachine.py | 6 +- homeassistant/components/switch/rflink.py | 2 +- homeassistant/components/switch/services.yaml | 78 ++- homeassistant/components/switch/skybell.py | 75 +++ homeassistant/components/switch/tellstick.py | 2 +- homeassistant/components/switch/telnet.py | 144 +++++ homeassistant/components/switch/template.py | 7 +- homeassistant/components/switch/tesla.py | 53 ++ homeassistant/components/switch/toon.py | 77 +++ homeassistant/components/switch/tplink.py | 29 +- homeassistant/components/switch/wink.py | 194 +++++- .../switch/{xiaomi.py => xiaomi_aqara.py} | 5 +- .../components/switch/xiaomi_miio.py | 168 +++++ .../components/telegram_bot/__init__.py | 2 +- homeassistant/components/tellduslive.py | 5 +- homeassistant/components/tellstick.py | 2 +- homeassistant/components/tesla.py | 7 +- homeassistant/components/thethingsnetwork.py | 47 ++ homeassistant/components/toon.py | 149 +++++ homeassistant/components/tradfri.py | 17 +- homeassistant/components/upnp.py | 91 ++- homeassistant/components/usps.py | 7 +- homeassistant/components/vacuum/__init__.py | 10 +- homeassistant/components/vacuum/demo.py | 6 +- homeassistant/components/vacuum/dyson.py | 2 +- homeassistant/components/vacuum/mqtt.py | 496 ++++++++++++++ homeassistant/components/vacuum/roomba.py | 4 +- .../vacuum/{xiaomi.py => xiaomi_miio.py} | 39 +- homeassistant/components/vera.py | 9 +- .../components/weather/openweathermap.py | 22 +- homeassistant/components/wink.py | 109 +++- .../components/{xiaomi.py => xiaomi_aqara.py} | 30 +- homeassistant/components/zha/__init__.py | 19 +- homeassistant/components/zwave/__init__.py | 2 +- homeassistant/components/zwave/services.yaml | 2 +- homeassistant/config.py | 22 +- homeassistant/const.py | 10 +- homeassistant/core.py | 11 +- homeassistant/helpers/aiohttp_client.py | 16 +- homeassistant/helpers/config_validation.py | 60 +- homeassistant/helpers/discovery.py | 9 + homeassistant/helpers/dispatcher.py | 5 + homeassistant/helpers/entity.py | 33 +- homeassistant/helpers/entity_component.py | 56 +- homeassistant/helpers/event.py | 93 ++- homeassistant/{util => helpers}/icon.py | 6 +- homeassistant/helpers/intent.py | 3 + homeassistant/helpers/restore_state.py | 2 + homeassistant/helpers/script.py | 4 +- homeassistant/helpers/service.py | 5 +- homeassistant/helpers/signal.py | 2 + homeassistant/helpers/state.py | 3 + homeassistant/helpers/sun.py | 5 + homeassistant/helpers/template.py | 30 +- homeassistant/loader.py | 32 +- homeassistant/package_constraints.txt | 3 +- homeassistant/scripts/benchmark/__init__.py | 74 ++- homeassistant/scripts/credstash.py | 1 + homeassistant/scripts/influxdb_migrator.py | 4 +- homeassistant/scripts/macos/launchd.plist | 2 + homeassistant/util/__init__.py | 2 +- homeassistant/util/color.py | 4 +- homeassistant/util/decorator.py | 14 + homeassistant/util/location.py | 2 +- homeassistant/util/logging.py | 2 +- homeassistant/util/yaml.py | 17 +- requirements_all.txt | 192 ++++-- requirements_docs.txt | 2 +- requirements_test_all.txt | 28 +- script/bootstrap_frontend | 5 +- script/gen_requirements_all.py | 22 +- script/test_docker | 2 +- setup.py | 3 +- tests/common.py | 2 +- .../alarm_control_panel/test_manual.py | 131 ++++ .../alarm_control_panel/test_manual_mqtt.py | 363 ++++++++++- .../alarm_control_panel/test_spc.py | 2 +- tests/components/alexa/test_smart_home.py | 261 ++++++++ tests/components/automation/test_event.py | 28 + .../automation/test_numeric_state.py | 34 + tests/components/automation/test_state.py | 35 + tests/components/automation/test_template.py | 35 + tests/components/binary_sensor/test_aurora.py | 2 +- tests/components/binary_sensor/test_mqtt.py | 70 +- tests/components/binary_sensor/test_spc.py | 2 +- .../components/binary_sensor/test_template.py | 40 +- .../binary_sensor/test_threshold.py | 50 ++ tests/components/camera/test_init.py | 2 +- tests/components/climate/test_demo.py | 4 +- tests/components/climate/test_ecobee.py | 452 +++++++++++++ tests/components/climate/test_mqtt.py | 420 ++++++++++++ tests/components/cloud/test_auth_api.py | 239 +++---- tests/components/cloud/test_http_api.py | 135 ++-- tests/components/cloud/test_init.py | 135 ++++ tests/components/cloud/test_iot.py | 243 +++++++ tests/components/cover/test_mqtt.py | 129 +++- tests/components/cover/test_template.py | 12 +- tests/components/device_tracker/test_init.py | 33 +- .../device_tracker/test_owntracks.py | 86 ++- .../device_tracker/test_owntracks_http.py | 60 ++ tests/components/device_tracker/test_unifi.py | 72 ++- .../device_tracker/test_upc_connect.py | 20 +- tests/components/emulated_hue/test_hue_api.py | 9 + tests/components/emulated_hue/test_init.py | 2 +- tests/components/google_assistant/__init__.py | 173 +++++ .../google_assistant/test_google_assistant.py | 214 ++++++ .../google_assistant/test_smart_home.py | 87 +++ tests/components/group/__init__.py | 1 + .../{test_group.py => group/test_init.py} | 0 tests/components/http/test_auth.py | 44 ++ .../components/image_processing/test_init.py | 4 +- tests/components/light/test_mochad.py | 2 +- tests/components/light/test_template.py | 76 +-- .../components/media_player/test_monoprice.py | 323 +++++++++ .../components/media_player/test_universal.py | 4 +- tests/components/media_player/test_yamaha.py | 4 +- tests/components/mqtt/test_discovery.py | 17 + .../persistent_notification/__init__.py | 1 + .../test_init.py} | 0 tests/components/recorder/test_init.py | 3 +- tests/components/recorder/test_purge.py | 48 +- .../sensor/test_dte_energy_bridge.py | 68 ++ .../components/sensor/test_geo_rss_events.py | 143 ++++ tests/components/sensor/test_mfi.py | 2 +- tests/components/sensor/test_statistics.py | 27 + tests/components/sensor/test_template.py | 42 +- tests/components/sensor/test_uptime.py | 88 +++ tests/components/switch/test_mochad.py | 2 +- tests/components/switch/test_mqtt.py | 2 +- tests/components/switch/test_rflink.py | 85 ++- tests/components/switch/test_template.py | 48 +- tests/components/test_duckdns.py | 106 +++ tests/components/test_hassio.py | 156 ++++- tests/components/test_history.py | 56 +- tests/components/test_history_graph.py | 46 ++ tests/components/test_influxdb.py | 2 +- tests/components/test_init.py | 2 +- tests/components/test_input_datetime.py | 204 ++++++ ...t_input_slider.py => test_input_number.py} | 70 +- tests/components/test_logbook.py | 2 +- tests/components/test_mqtt_statestream.py | 93 ++- tests/components/test_namecheapdns.py | 78 +++ tests/components/test_python_script.py | 111 +++- tests/components/test_rflink.py | 35 +- tests/components/test_splunk.py | 34 +- tests/components/test_upnp.py | 142 ++++ tests/components/vacuum/test_mqtt.py | 199 ++++++ .../{test_xiaomi.py => test_xiaomi_miio.py} | 192 ++++-- tests/fixtures/geo_rss_events.xml | 76 +++ tests/helpers/test_config_validation.py | 40 ++ tests/helpers/test_discovery.py | 15 +- tests/helpers/test_entity.py | 159 +++++ tests/helpers/test_entity_component.py | 82 ++- tests/helpers/test_event.py | 24 +- tests/{util => helpers}/test_icon.py | 6 +- tests/helpers/test_restore_state.py | 79 +++ tests/helpers/test_script.py | 35 + tests/helpers/test_template.py | 35 +- tests/test_config.py | 42 +- tests/test_loader.py | 19 + tests/util/test_async.py | 100 ++- tests/util/test_init.py | 16 +- tests/util/test_logging.py | 68 ++ tests/util/test_yaml.py | 14 +- virtualization/Docker/Dockerfile.dev | 2 +- virtualization/Docker/scripts/aiocoap | 23 + virtualization/Docker/scripts/coap_client | 17 - virtualization/Docker/setup_docker_prereqs | 6 +- 467 files changed, 19572 insertions(+), 3840 deletions(-) create mode 100644 homeassistant/components/alarm_control_panel/arlo.py create mode 100644 homeassistant/components/alarm_control_panel/satel_integra.py create mode 100644 homeassistant/components/alexa/smart_home.py create mode 100644 homeassistant/components/binary_sensor/doorbird.py create mode 100644 homeassistant/components/binary_sensor/raincloud.py create mode 100644 homeassistant/components/binary_sensor/satel_integra.py create mode 100644 homeassistant/components/binary_sensor/skybell.py rename homeassistant/components/binary_sensor/{xiaomi.py => xiaomi_aqara.py} (95%) create mode 100644 homeassistant/components/calendar/services.yaml create mode 100644 homeassistant/components/calendar/todoist.py create mode 100644 homeassistant/components/camera/abode.py create mode 100644 homeassistant/components/camera/doorbird.py create mode 100644 homeassistant/components/camera/skybell.py create mode 100644 homeassistant/components/camera/yi.py create mode 100644 homeassistant/components/climate/mqtt.py create mode 100644 homeassistant/components/climate/toon.py create mode 100644 homeassistant/components/cloud/iot.py delete mode 100644 homeassistant/components/cloud/util.py create mode 100644 homeassistant/components/cover/rflink.py rename homeassistant/components/cover/{xiaomi.py => xiaomi_aqara.py} (94%) create mode 100644 homeassistant/components/device_tracker/keenetic_ndms2.py create mode 100644 homeassistant/components/device_tracker/owntracks_http.py create mode 100644 homeassistant/components/doorbird.py create mode 100644 homeassistant/components/duckdns.py create mode 100644 homeassistant/components/google_assistant/__init__.py create mode 100644 homeassistant/components/google_assistant/auth.py create mode 100644 homeassistant/components/google_assistant/const.py create mode 100644 homeassistant/components/google_assistant/http.py create mode 100644 homeassistant/components/google_assistant/smart_home.py rename homeassistant/components/{group.py => group/__init__.py} (98%) create mode 100644 homeassistant/components/group/services.yaml create mode 100644 homeassistant/components/history_graph.py create mode 100644 homeassistant/components/input_datetime.py rename homeassistant/components/{input_slider.py => input_number.py} (77%) create mode 100644 homeassistant/components/light/abode.py create mode 100644 homeassistant/components/light/skybell.py rename homeassistant/components/light/{xiaomi.py => xiaomi_aqara.py} (95%) rename homeassistant/components/light/{xiaomi_philipslight.py => xiaomi_miio.py} (92%) create mode 100644 homeassistant/components/map.py create mode 100644 homeassistant/components/media_player/monoprice.py create mode 100644 homeassistant/components/namecheapdns.py create mode 100644 homeassistant/components/notify/clicksend_tts.py create mode 100644 homeassistant/components/notify/rocketchat.py rename homeassistant/components/{persistent_notification.py => persistent_notification/__init__.py} (96%) create mode 100644 homeassistant/components/persistent_notification/services.yaml create mode 100644 homeassistant/components/raincloud.py create mode 100644 homeassistant/components/recorder/services.yaml create mode 100644 homeassistant/components/satel_integra.py create mode 100644 homeassistant/components/sensor/abode.py create mode 100644 homeassistant/components/sensor/geo_rss_events.py create mode 100644 homeassistant/components/sensor/raincloud.py create mode 100644 homeassistant/components/sensor/serial.py create mode 100644 homeassistant/components/sensor/skybell.py create mode 100644 homeassistant/components/sensor/thethingsnetwork.py create mode 100644 homeassistant/components/sensor/tibber.py create mode 100644 homeassistant/components/sensor/toon.py create mode 100644 homeassistant/components/sensor/tradfri.py create mode 100644 homeassistant/components/sensor/travisci.py create mode 100644 homeassistant/components/sensor/uptime.py create mode 100644 homeassistant/components/sensor/worxlandroid.py rename homeassistant/components/sensor/{xiaomi.py => xiaomi_aqara.py} (90%) create mode 100644 homeassistant/components/skybell.py create mode 100644 homeassistant/components/switch/doorbird.py create mode 100644 homeassistant/components/switch/raincloud.py create mode 100644 homeassistant/components/switch/skybell.py create mode 100644 homeassistant/components/switch/telnet.py create mode 100644 homeassistant/components/switch/tesla.py create mode 100644 homeassistant/components/switch/toon.py rename homeassistant/components/switch/{xiaomi.py => xiaomi_aqara.py} (96%) create mode 100644 homeassistant/components/switch/xiaomi_miio.py create mode 100644 homeassistant/components/thethingsnetwork.py create mode 100644 homeassistant/components/toon.py create mode 100644 homeassistant/components/vacuum/mqtt.py rename homeassistant/components/vacuum/{xiaomi.py => xiaomi_miio.py} (88%) rename homeassistant/components/{xiaomi.py => xiaomi_aqara.py} (88%) rename homeassistant/{util => helpers}/icon.py (85%) create mode 100644 homeassistant/util/decorator.py create mode 100644 tests/components/alexa/test_smart_home.py create mode 100644 tests/components/climate/test_ecobee.py create mode 100644 tests/components/climate/test_mqtt.py create mode 100644 tests/components/cloud/test_init.py create mode 100644 tests/components/cloud/test_iot.py create mode 100644 tests/components/device_tracker/test_owntracks_http.py create mode 100644 tests/components/google_assistant/__init__.py create mode 100644 tests/components/google_assistant/test_google_assistant.py create mode 100644 tests/components/google_assistant/test_smart_home.py create mode 100644 tests/components/group/__init__.py rename tests/components/{test_group.py => group/test_init.py} (100%) create mode 100644 tests/components/media_player/test_monoprice.py create mode 100644 tests/components/persistent_notification/__init__.py rename tests/components/{test_persistent_notification.py => persistent_notification/test_init.py} (100%) create mode 100644 tests/components/sensor/test_dte_energy_bridge.py create mode 100644 tests/components/sensor/test_geo_rss_events.py create mode 100644 tests/components/sensor/test_uptime.py create mode 100644 tests/components/test_duckdns.py create mode 100644 tests/components/test_history_graph.py create mode 100644 tests/components/test_input_datetime.py rename tests/components/{test_input_slider.py => test_input_number.py} (62%) create mode 100644 tests/components/test_namecheapdns.py create mode 100644 tests/components/test_upnp.py create mode 100644 tests/components/vacuum/test_mqtt.py rename tests/components/vacuum/{test_xiaomi.py => test_xiaomi_miio.py} (54%) create mode 100644 tests/fixtures/geo_rss_events.xml rename tests/{util => helpers}/test_icon.py (92%) create mode 100644 tests/util/test_logging.py create mode 100755 virtualization/Docker/scripts/aiocoap delete mode 100755 virtualization/Docker/scripts/coap_client diff --git a/.coveragerc b/.coveragerc index d5eb32e670c280..e16b8d966003e8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -53,6 +53,9 @@ omit = homeassistant/components/digital_ocean.py homeassistant/components/*/digital_ocean.py + homeassistant/components/doorbird.py + homeassistant/components/*/doorbird.py + homeassistant/components/dweet.py homeassistant/components/*/dweet.py @@ -146,6 +149,9 @@ omit = homeassistant/components/rachio.py homeassistant/components/*/rachio.py + homeassistant/components/raincloud.py + homeassistant/components/*/raincloud.py + homeassistant/components/raspihats.py homeassistant/components/*/raspihats.py @@ -158,9 +164,15 @@ omit = homeassistant/components/rpi_pfio.py homeassistant/components/*/rpi_pfio.py + homeassistant/components/satel_integra.py + homeassistant/components/*/satel_integra.py + homeassistant/components/scsgate.py homeassistant/components/*/scsgate.py + homeassistant/components/skybell.py + homeassistant/components/*/skybell.py + homeassistant/components/tado.py homeassistant/components/*/tado.py @@ -173,8 +185,14 @@ omit = homeassistant/components/tesla.py homeassistant/components/*/tesla.py + homeassistant/components/thethingsnetwork.py + homeassistant/components/*/thethingsnetwork.py + homeassistant/components/*/thinkingcleaner.py + homeassistant/components/toon.py + homeassistant/components/*/toon.py + homeassistant/components/tradfri.py homeassistant/components/*/tradfri.py @@ -208,12 +226,12 @@ omit = homeassistant/components/wink.py homeassistant/components/*/wink.py - homeassistant/components/xiaomi.py - homeassistant/components/binary_sensor/xiaomi.py - homeassistant/components/cover/xiaomi.py - homeassistant/components/light/xiaomi.py - homeassistant/components/sensor/xiaomi.py - homeassistant/components/switch/xiaomi.py + homeassistant/components/xiaomi_aqara.py + homeassistant/components/binary_sensor/xiaomi_aqara.py + homeassistant/components/cover/xiaomi_aqara.py + homeassistant/components/light/xiaomi_aqara.py + homeassistant/components/sensor/xiaomi_aqara.py + homeassistant/components/switch/xiaomi_aqara.py homeassistant/components/zabbix.py homeassistant/components/*/zabbix.py @@ -247,6 +265,7 @@ omit = homeassistant/components/binary_sensor/rest.py homeassistant/components/binary_sensor/tapsaff.py homeassistant/components/browser.py + homeassistant/components/calendar/todoist.py homeassistant/components/camera/bloomsky.py homeassistant/components/camera/ffmpeg.py homeassistant/components/camera/foscam.py @@ -254,6 +273,7 @@ omit = homeassistant/components/camera/rpi_camera.py homeassistant/components/camera/onvif.py homeassistant/components/camera/synology.py + homeassistant/components/camera/yi.py homeassistant/components/climate/eq3btsmart.py homeassistant/components/climate/flexit.py homeassistant/components/climate/heatmiser.py @@ -283,6 +303,7 @@ omit = homeassistant/components/device_tracker/gpslogger.py homeassistant/components/device_tracker/huawei_router.py homeassistant/components/device_tracker/icloud.py + homeassistant/components/device_tracker/keenetic_ndms2.py homeassistant/components/device_tracker/linksys_ap.py homeassistant/components/device_tracker/linksys_smart.py homeassistant/components/device_tracker/luci.py @@ -331,7 +352,7 @@ omit = homeassistant/components/light/tplink.py homeassistant/components/light/tradfri.py homeassistant/components/light/x10.py - homeassistant/components/light/xiaomi_philipslight.py + homeassistant/components/light/xiaomi_miio.py homeassistant/components/light/yeelight.py homeassistant/components/light/yeelightsunflower.py homeassistant/components/light/zengge.py @@ -391,6 +412,7 @@ omit = homeassistant/components/notify/aws_sqs.py homeassistant/components/notify/ciscospark.py homeassistant/components/notify/clicksend.py + homeassistant/components/notify/clicksend_tts.py homeassistant/components/notify/discord.py homeassistant/components/notify/facebook.py homeassistant/components/notify/free_mobile.py @@ -412,6 +434,7 @@ omit = homeassistant/components/notify/pushover.py homeassistant/components/notify/pushsafer.py homeassistant/components/notify/rest.py + homeassistant/components/notify/rocketchat.py homeassistant/components/notify/sendgrid.py homeassistant/components/notify/simplepush.py homeassistant/components/notify/slack.py @@ -514,6 +537,7 @@ omit = homeassistant/components/sensor/sabnzbd.py homeassistant/components/sensor/scrape.py homeassistant/components/sensor/sensehat.py + homeassistant/components/sensor/serial.py homeassistant/components/sensor/serial_pm.py homeassistant/components/sensor/shodan.py homeassistant/components/sensor/skybeacon.py @@ -530,9 +554,11 @@ omit = homeassistant/components/sensor/tank_utility.py homeassistant/components/sensor/ted5000.py homeassistant/components/sensor/temper.py + homeassistant/components/sensor/tibber.py homeassistant/components/sensor/time_date.py homeassistant/components/sensor/torque.py homeassistant/components/sensor/transmission.py + homeassistant/components/sensor/travisci.py homeassistant/components/sensor/twitch.py homeassistant/components/sensor/uber.py homeassistant/components/sensor/upnp.py @@ -540,6 +566,7 @@ omit = homeassistant/components/sensor/vasttrafik.py homeassistant/components/sensor/waqi.py homeassistant/components/sensor/worldtidesinfo.py + homeassistant/components/sensor/worxlandroid.py homeassistant/components/sensor/xbox_live.py homeassistant/components/sensor/yweather.py homeassistant/components/sensor/zamg.py @@ -565,13 +592,14 @@ omit = homeassistant/components/switch/rest.py homeassistant/components/switch/rpi_rf.py homeassistant/components/switch/tplink.py + homeassistant/components/switch/telnet.py homeassistant/components/switch/transmission.py homeassistant/components/switch/wake_on_lan.py + homeassistant/components/switch/xiaomi_miio.py homeassistant/components/telegram_bot/* homeassistant/components/thingspeak.py homeassistant/components/tts/amazon_polly.py homeassistant/components/tts/picotts.py - homeassistant/components/upnp.py homeassistant/components/vacuum/roomba.py homeassistant/components/weather/bom.py homeassistant/components/weather/buienradar.py @@ -581,6 +609,7 @@ omit = homeassistant/components/weather/zamg.py homeassistant/components/zeroconf.py homeassistant/components/zwave/util.py + homeassistant/components/vacuum/mqtt.py [report] diff --git a/CODEOWNERS b/CODEOWNERS index 3c975ca3862423..0560f5d53109c1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -29,6 +29,9 @@ homeassistant/components/weblink.py @home-assistant/core homeassistant/components/websocket_api.py @home-assistant/core homeassistant/components/zone.py @home-assistant/core +# To monitor non-pypi additions +requirements_all.txt @andrey-git + Dockerfile @home-assistant/docker virtualization/Docker/* @home-assistant/docker @@ -36,6 +39,28 @@ homeassistant/components/zwave/* @home-assistant/z-wave homeassistant/components/*/zwave.py @home-assistant/z-wave # Indiviudal components +homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt +homeassistant/components/camera/yi.py @bachya +homeassistant/components/climate/eq3btsmart.py @rytilahti +homeassistant/components/climate/sensibo.py @andrey-git homeassistant/components/cover/template.py @PhracturedBlue homeassistant/components/device_tracker/automatic.py @armills +homeassistant/components/history_graph.py @andrey-git +homeassistant/components/light/tplink.py @rytilahti +homeassistant/components/light/yeelight.py @rytilahti homeassistant/components/media_player/kodi.py @armills +homeassistant/components/media_player/monoprice.py @etsinko +homeassistant/components/sensor/airvisual.py @bachya +homeassistant/components/sensor/miflora.py @danielhiversen +homeassistant/components/sensor/tibber.py @danielhiversen +homeassistant/components/sensor/waqi.py @andrey-git +homeassistant/components/switch/rainmachine.py @bachya +homeassistant/components/switch/tplink.py @rytilahti + +homeassistant/components/*/axis.py @Kane610 +homeassistant/components/*/broadlink.py @danielhiversen +homeassistant/components/*/rfxtrx.py @danielhiversen +homeassistant/components/tesla.py @zabuldon +homeassistant/components/*/tesla.py @zabuldon +homeassistant/components/*/xiaomi_aqara.py @danielhiversen +homeassistant/components/*/xiaomi_miio.py @rytilahti diff --git a/Dockerfile b/Dockerfile index f0d5accdf3d300..908e8481eee69d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,9 +11,10 @@ MAINTAINER Paulus Schoutsen #ENV INSTALL_FFMPEG no #ENV INSTALL_LIBCEC no #ENV INSTALL_PHANTOMJS no -#ENV INSTALL_COAP_CLIENT no +#ENV INSTALL_COAP no #ENV INSTALL_SSOCR no + VOLUME /config RUN mkdir -p /usr/src/app @@ -25,7 +26,6 @@ RUN virtualization/Docker/setup_docker_prereqs # Install hass component dependencies COPY requirements_all.txt requirements_all.txt - # Uninstall enum34 because some depenndecies install it but breaks Python 3.4+. # See PR #8103 for more info. RUN pip3 install --no-cache-dir -r requirements_all.txt && \ diff --git a/docs/source/conf.py b/docs/source/conf.py index bcb2699f57b37e..8ca22e1a126bda 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -117,7 +117,11 @@ def linkcode_resolve(domain, info): linespec = "#L%d" % (lineno + 1) else: linespec = "" - fn = relpath(fn, start='../') + index = fn.find("/homeassistant/") + if index == -1: + index = 0 + + fn = fn[index:] return '{}/blob/{}/{}{}'.format(GITHUB_URL, code_branch, fn, linespec) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 2ce574ca15e583..a8852b910c24e5 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -126,6 +126,12 @@ def get_arguments() -> argparse.Namespace: type=int, default=None, help='Enables daily log rotation and keeps up to the specified days') + parser.add_argument( + '--log-file', + type=str, + default=None, + help='Log file to write to. If not set, CONFIG/home-assistant.log ' + 'is used') parser.add_argument( '--runner', action='store_true', @@ -256,13 +262,14 @@ def setup_and_run_hass(config_dir: str, } hass = bootstrap.from_config_dict( config, config_dir=config_dir, verbose=args.verbose, - skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days) + skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days, + log_file=args.log_file) else: config_file = ensure_config_file(config_dir) print('Config directory:', config_dir) hass = bootstrap.from_config_file( config_file, verbose=args.verbose, skip_pip=args.skip_pip, - log_rotate_days=args.log_rotate_days) + log_rotate_days=args.log_rotate_days, log_file=args.log_file) if hass is None: return None diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7831036ff597e6..4978177a658379 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -11,13 +11,11 @@ import voluptuous as vol -import homeassistant.components as core_components +from homeassistant import ( + core, config as conf_util, loader, components as core_components) from homeassistant.components import persistent_notification -import homeassistant.config as conf_util -import homeassistant.core as core from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE from homeassistant.setup import async_setup_component -import homeassistant.loader as loader from homeassistant.util.logging import AsyncHandler from homeassistant.util.package import async_get_user_site, get_user_site from homeassistant.util.yaml import clear_secret_cache @@ -27,6 +25,10 @@ _LOGGER = logging.getLogger(__name__) ERROR_LOG_FILENAME = 'home-assistant.log' + +# hass.data key for logging information. +DATA_LOGGING = 'logging' + FIRST_INIT_COMPONENT = set(( 'recorder', 'mqtt', 'mqtt_eventstream', 'logger', 'introduction', 'frontend', 'history')) @@ -38,7 +40,8 @@ def from_config_dict(config: Dict[str, Any], enable_log: bool=True, verbose: bool=False, skip_pip: bool=False, - log_rotate_days: Any=None) \ + log_rotate_days: Any=None, + log_file: Any=None) \ -> Optional[core.HomeAssistant]: """Try to configure Home Assistant from a configuration dictionary. @@ -56,7 +59,7 @@ def from_config_dict(config: Dict[str, Any], hass = hass.loop.run_until_complete( async_from_config_dict( config, hass, config_dir, enable_log, verbose, skip_pip, - log_rotate_days) + log_rotate_days, log_file) ) return hass @@ -69,7 +72,8 @@ def async_from_config_dict(config: Dict[str, Any], enable_log: bool=True, verbose: bool=False, skip_pip: bool=False, - log_rotate_days: Any=None) \ + log_rotate_days: Any=None, + log_file: Any=None) \ -> Optional[core.HomeAssistant]: """Try to configure Home Assistant from a configuration dictionary. @@ -77,6 +81,18 @@ def async_from_config_dict(config: Dict[str, Any], This method is a coroutine. """ start = time() + + if enable_log: + async_enable_logging(hass, verbose, log_rotate_days, log_file) + + if sys.version_info[:2] < (3, 5): + _LOGGER.warning( + 'Python 3.4 support has been deprecated and will be removed in ' + 'the begining of 2018. Please upgrade Python or your operating ' + 'system. More info: https://home-assistant.io/blog/2017/10/06/' + 'deprecating-python-3.4-support/' + ) + core_config = config.get(core.DOMAIN, {}) try: @@ -87,9 +103,6 @@ def async_from_config_dict(config: Dict[str, Any], yield from hass.async_add_job(conf_util.process_ha_config_upgrade, hass) - if enable_log: - async_enable_logging(hass, verbose, log_rotate_days) - hass.config.skip_pip = skip_pip if skip_pip: _LOGGER.warning("Skipping pip installation of required modules. " @@ -153,7 +166,8 @@ def from_config_file(config_path: str, hass: Optional[core.HomeAssistant]=None, verbose: bool=False, skip_pip: bool=True, - log_rotate_days: Any=None): + log_rotate_days: Any=None, + log_file: Any=None): """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter if given, @@ -165,7 +179,7 @@ def from_config_file(config_path: str, # run task hass = hass.loop.run_until_complete( async_from_config_file( - config_path, hass, verbose, skip_pip, log_rotate_days) + config_path, hass, verbose, skip_pip, log_rotate_days, log_file) ) return hass @@ -176,7 +190,8 @@ def async_from_config_file(config_path: str, hass: core.HomeAssistant, verbose: bool=False, skip_pip: bool=True, - log_rotate_days: Any=None): + log_rotate_days: Any=None, + log_file: Any=None): """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter. @@ -187,7 +202,7 @@ def async_from_config_file(config_path: str, hass.config.config_dir = config_dir yield from async_mount_local_lib_path(config_dir, hass.loop) - async_enable_logging(hass, verbose, log_rotate_days) + async_enable_logging(hass, verbose, log_rotate_days, log_file) try: config_dict = yield from hass.async_add_job( @@ -205,7 +220,7 @@ def async_from_config_file(config_path: str, @core.callback def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False, - log_rotate_days=None) -> None: + log_rotate_days=None, log_file=None) -> None: """Set up the logging. This method must be run in the event loop. @@ -239,13 +254,18 @@ def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False, pass # Log errors to a file if we have write access to file or config dir - err_log_path = hass.config.path(ERROR_LOG_FILENAME) + if log_file is None: + err_log_path = hass.config.path(ERROR_LOG_FILENAME) + else: + err_log_path = os.path.abspath(log_file) + err_path_exists = os.path.isfile(err_log_path) + err_dir = os.path.dirname(err_log_path) # Check if we can write to the error log if it exists or that # we can create files in the containing directory if not. if (err_path_exists and os.access(err_log_path, os.W_OK)) or \ - (not err_path_exists and os.access(hass.config.config_dir, os.W_OK)): + (not err_path_exists and os.access(err_dir, os.W_OK)): if log_rotate_days: err_handler = logging.handlers.TimedRotatingFileHandler( @@ -272,6 +292,8 @@ def async_stop_async_handler(event): logger.addHandler(async_handler) logger.setLevel(logging.INFO) + # Save the log file location for access by other components. + hass.data[DATA_LOGGING] = err_log_path else: _LOGGER.error( "Unable to setup error log %s (access denied)", err_log_path) diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index 6db147a5f59323..b5ac57080d1ec1 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -10,6 +10,7 @@ import asyncio import itertools as it import logging +import os import homeassistant.core as ha import homeassistant.config as conf_util @@ -110,6 +111,11 @@ def async_reload_core_config(hass): @asyncio.coroutine def async_setup(hass, config): """Set up general services related to Home Assistant.""" + descriptions = yield from hass.async_add_job( + conf_util.load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml') + ) + @asyncio.coroutine def async_handle_turn_service(service): """Handle calls to homeassistant.turn_on/off.""" @@ -149,11 +155,14 @@ def async_handle_turn_service(service): yield from asyncio.wait(tasks, loop=hass.loop) hass.services.async_register( - ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service) + ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service, + descriptions[ha.DOMAIN][SERVICE_TURN_OFF]) hass.services.async_register( - ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service) + ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service, + descriptions[ha.DOMAIN][SERVICE_TURN_ON]) hass.services.async_register( - ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service) + ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service, + descriptions[ha.DOMAIN][SERVICE_TOGGLE]) @asyncio.coroutine def async_handle_core_service(call): @@ -178,11 +187,14 @@ def async_handle_core_service(call): hass.async_add_job(hass.async_stop(RESTART_EXIT_CODE)) hass.services.async_register( - ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service) + ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service, + descriptions[ha.DOMAIN][SERVICE_HOMEASSISTANT_STOP]) hass.services.async_register( - ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service) + ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service, + descriptions[ha.DOMAIN][SERVICE_HOMEASSISTANT_RESTART]) hass.services.async_register( - ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service) + ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service, + descriptions[ha.DOMAIN][SERVICE_CHECK_CONFIG]) @asyncio.coroutine def async_handle_reload_config(call): @@ -197,6 +209,7 @@ def async_handle_reload_config(call): hass, conf.get(ha.DOMAIN) or {}) hass.services.async_register( - ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config) + ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config, + descriptions[ha.DOMAIN][SERVICE_RELOAD_CORE_CONFIG]) return True diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index f3283eff74865e..581045c3790df4 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -6,57 +6,136 @@ """ import asyncio import logging +from functools import partial +from os import path import voluptuous as vol -from requests.exceptions import HTTPError, ConnectTimeout -from homeassistant.helpers import discovery + +from homeassistant.config import load_yaml_config_file +from homeassistant.const import ( + ATTR_ATTRIBUTION, ATTR_DATE, ATTR_TIME, ATTR_ENTITY_ID, CONF_USERNAME, + CONF_PASSWORD, CONF_EXCLUDE, CONF_NAME, CONF_LIGHTS, + EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -from homeassistant.const import (ATTR_ATTRIBUTION, - CONF_USERNAME, CONF_PASSWORD, - CONF_NAME, EVENT_HOMEASSISTANT_STOP, - EVENT_HOMEASSISTANT_START) +from requests.exceptions import HTTPError, ConnectTimeout -REQUIREMENTS = ['abodepy==0.9.0'] +REQUIREMENTS = ['abodepy==0.12.1'] _LOGGER = logging.getLogger(__name__) CONF_ATTRIBUTION = "Data provided by goabode.com" +CONF_POLLING = 'polling' DOMAIN = 'abode' -DEFAULT_NAME = 'Abode' -DATA_ABODE = 'abode' NOTIFICATION_ID = 'abode_notification' NOTIFICATION_TITLE = 'Abode Security Setup' +EVENT_ABODE_ALARM = 'abode_alarm' +EVENT_ABODE_ALARM_END = 'abode_alarm_end' +EVENT_ABODE_AUTOMATION = 'abode_automation' +EVENT_ABODE_FAULT = 'abode_panel_fault' +EVENT_ABODE_RESTORE = 'abode_panel_restore' + +SERVICE_SETTINGS = 'change_setting' +SERVICE_CAPTURE_IMAGE = 'capture_image' +SERVICE_TRIGGER = 'trigger_quick_action' + +ATTR_DEVICE_ID = 'device_id' +ATTR_DEVICE_NAME = 'device_name' +ATTR_DEVICE_TYPE = 'device_type' +ATTR_EVENT_CODE = 'event_code' +ATTR_EVENT_NAME = 'event_name' +ATTR_EVENT_TYPE = 'event_type' +ATTR_EVENT_UTC = 'event_utc' +ATTR_SETTING = 'setting' +ATTR_USER_NAME = 'user_name' +ATTR_VALUE = 'value' + +ABODE_DEVICE_ID_LIST_SCHEMA = vol.Schema([str]) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_POLLING, default=False): cv.boolean, + vol.Optional(CONF_EXCLUDE, default=[]): ABODE_DEVICE_ID_LIST_SCHEMA, + vol.Optional(CONF_LIGHTS, default=[]): ABODE_DEVICE_ID_LIST_SCHEMA }), }, extra=vol.ALLOW_EXTRA) +CHANGE_SETTING_SCHEMA = vol.Schema({ + vol.Required(ATTR_SETTING): cv.string, + vol.Required(ATTR_VALUE): cv.string +}) + +CAPTURE_IMAGE_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, +}) + +TRIGGER_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, +}) + ABODE_PLATFORMS = [ - 'alarm_control_panel', 'binary_sensor', 'lock', 'switch', 'cover' + 'alarm_control_panel', 'binary_sensor', 'lock', 'switch', 'cover', + 'camera', 'light' ] +class AbodeSystem(object): + """Abode System class.""" + + def __init__(self, username, password, name, polling, exclude, lights): + """Initialize the system.""" + import abodepy + self.abode = abodepy.Abode( + username, password, auto_login=True, get_devices=True, + get_automations=True) + self.name = name + self.polling = polling + self.exclude = exclude + self.lights = lights + self.devices = [] + + def is_excluded(self, device): + """Check if a device is configured to be excluded.""" + return device.device_id in self.exclude + + def is_automation_excluded(self, automation): + """Check if an automation is configured to be excluded.""" + return automation.automation_id in self.exclude + + def is_light(self, device): + """Check if a switch device is configured as a light.""" + import abodepy.helpers.constants as CONST + + return (device.generic_type == CONST.TYPE_LIGHT or + (device.generic_type == CONST.TYPE_SWITCH and + device.device_id in self.lights)) + + def setup(hass, config): """Set up Abode component.""" - import abodepy + from abodepy.exceptions import AbodeException conf = config[DOMAIN] username = conf.get(CONF_USERNAME) password = conf.get(CONF_PASSWORD) + name = conf.get(CONF_NAME) + polling = conf.get(CONF_POLLING) + exclude = conf.get(CONF_EXCLUDE) + lights = conf.get(CONF_LIGHTS) try: - hass.data[DATA_ABODE] = abode = abodepy.Abode( - username, password, auto_login=True, get_devices=True) - - except (ConnectTimeout, HTTPError) as ex: + hass.data[DOMAIN] = AbodeSystem( + username, password, name, polling, exclude, lights) + except (AbodeException, ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Abode: %s", str(ex)) + hass.components.persistent_notification.create( 'Error: {}
' 'You will need to restart hass after fixing.' @@ -65,46 +144,144 @@ def setup(hass, config): notification_id=NOTIFICATION_ID) return False + setup_hass_services(hass) + setup_hass_events(hass) + setup_abode_events(hass) + for platform in ABODE_PLATFORMS: discovery.load_platform(hass, platform, DOMAIN, {}, config) + return True + + +def setup_hass_services(hass): + """Home assistant services.""" + from abodepy.exceptions import AbodeException + + def change_setting(call): + """Change an Abode system setting.""" + setting = call.data.get(ATTR_SETTING) + value = call.data.get(ATTR_VALUE) + + try: + hass.data[DOMAIN].abode.set_setting(setting, value) + except AbodeException as ex: + _LOGGER.warning(ex) + + def capture_image(call): + """Capture a new image.""" + entity_ids = call.data.get(ATTR_ENTITY_ID) + + target_devices = [device for device in hass.data[DOMAIN].devices + if device.entity_id in entity_ids] + + for device in target_devices: + device.capture() + + def trigger_quick_action(call): + """Trigger a quick action.""" + entity_ids = call.data.get(ATTR_ENTITY_ID, None) + + target_devices = [device for device in hass.data[DOMAIN].devices + if device.entity_id in entity_ids] + + for device in target_devices: + device.trigger() + + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml'))[DOMAIN] + + hass.services.register( + DOMAIN, SERVICE_SETTINGS, change_setting, + descriptions.get(SERVICE_SETTINGS), + schema=CHANGE_SETTING_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image, + descriptions.get(SERVICE_CAPTURE_IMAGE), + schema=CAPTURE_IMAGE_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_TRIGGER, trigger_quick_action, + descriptions.get(SERVICE_TRIGGER), + schema=TRIGGER_SCHEMA) + + +def setup_hass_events(hass): + """Home Assistant start and stop callbacks.""" + def startup(event): + """Listen for push events.""" + hass.data[DOMAIN].abode.events.start() + def logout(event): """Logout of Abode.""" - abode.stop_listener() - abode.logout() + if not hass.data[DOMAIN].polling: + hass.data[DOMAIN].abode.events.stop() + + hass.data[DOMAIN].abode.logout() _LOGGER.info("Logged out of Abode") + if not hass.data[DOMAIN].polling: + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, startup) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout) - def startup(event): - """Listen for push events.""" - abode.start_listener() - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, startup) +def setup_abode_events(hass): + """Event callbacks.""" + import abodepy.helpers.timeline as TIMELINE - return True + def event_callback(event, event_json): + """Handle an event callback from Abode.""" + data = { + ATTR_DEVICE_ID: event_json.get(ATTR_DEVICE_ID, ''), + ATTR_DEVICE_NAME: event_json.get(ATTR_DEVICE_NAME, ''), + ATTR_DEVICE_TYPE: event_json.get(ATTR_DEVICE_TYPE, ''), + ATTR_EVENT_CODE: event_json.get(ATTR_EVENT_CODE, ''), + ATTR_EVENT_NAME: event_json.get(ATTR_EVENT_NAME, ''), + ATTR_EVENT_TYPE: event_json.get(ATTR_EVENT_TYPE, ''), + ATTR_EVENT_UTC: event_json.get(ATTR_EVENT_UTC, ''), + ATTR_USER_NAME: event_json.get(ATTR_USER_NAME, ''), + ATTR_DATE: event_json.get(ATTR_DATE, ''), + ATTR_TIME: event_json.get(ATTR_TIME, ''), + } + + hass.bus.fire(event, data) + + events = [TIMELINE.ALARM_GROUP, TIMELINE.ALARM_END_GROUP, + TIMELINE.PANEL_FAULT_GROUP, TIMELINE.PANEL_RESTORE_GROUP, + TIMELINE.AUTOMATION_GROUP] + + for event in events: + hass.data[DOMAIN].abode.events.add_event_callback( + event, + partial(event_callback, event)) class AbodeDevice(Entity): """Representation of an Abode device.""" - def __init__(self, controller, device): + def __init__(self, data, device): """Initialize a sensor for Abode device.""" - self._controller = controller + self._data = data self._device = device @asyncio.coroutine def async_added_to_hass(self): """Subscribe Abode events.""" self.hass.async_add_job( - self._controller.register, self._device, - self._update_callback + self._data.abode.events.add_device_callback, + self._device.device_id, self._update_callback ) @property def should_poll(self): """Return the polling state.""" - return False + return self._data.polling + + def update(self): + """Update automation state.""" + self._device.refresh() @property def name(self): @@ -118,9 +295,58 @@ def device_state_attributes(self): ATTR_ATTRIBUTION: CONF_ATTRIBUTION, 'device_id': self._device.device_id, 'battery_low': self._device.battery_low, - 'no_response': self._device.no_response + 'no_response': self._device.no_response, + 'device_type': self._device.type + } + + def _update_callback(self, device): + """Update the device state.""" + self.schedule_update_ha_state() + + +class AbodeAutomation(Entity): + """Representation of an Abode automation.""" + + def __init__(self, data, automation, event=None): + """Initialize for Abode automation.""" + self._data = data + self._automation = automation + self._event = event + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe Abode events.""" + if self._event: + self.hass.async_add_job( + self._data.abode.events.add_event_callback, + self._event, self._update_callback + ) + + @property + def should_poll(self): + """Return the polling state.""" + return self._data.polling + + def update(self): + """Update automation state.""" + self._automation.refresh() + + @property + def name(self): + """Return the name of the sensor.""" + return self._automation.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'automation_id': self._automation.automation_id, + 'type': self._automation.type, + 'sub_type': self._automation.sub_type } def _update_callback(self, device): """Update the device state.""" + self._automation.refresh() self.schedule_update_ha_state() diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 005048ba8c1309..1141e42f9ef349 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -124,20 +124,13 @@ def async_alarm_service_handler(service): method = "async_{}".format(SERVICE_TO_METHOD[service.service]) + update_tasks = [] for alarm in target_alarms: yield from getattr(alarm, method)(code) - update_tasks = [] - for alarm in target_alarms: if not alarm.should_poll: continue - - update_coro = hass.async_add_job( - alarm.async_update_ha_state(True)) - if hasattr(alarm, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(alarm.async_update_ha_state(True)) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) diff --git a/homeassistant/components/alarm_control_panel/abode.py b/homeassistant/components/alarm_control_panel/abode.py index 7a615ffc7bf1ac..aa4e86a23184b2 100644 --- a/homeassistant/components/alarm_control_panel/abode.py +++ b/homeassistant/components/alarm_control_panel/abode.py @@ -7,7 +7,7 @@ import logging from homeassistant.components.abode import ( - AbodeDevice, DATA_ABODE, DEFAULT_NAME, CONF_ATTRIBUTION) + AbodeDevice, DOMAIN as ABODE_DOMAIN, CONF_ATTRIBUTION) from homeassistant.components.alarm_control_panel import (AlarmControlPanel) from homeassistant.const import (ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) @@ -22,18 +22,22 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a sensor for an Abode device.""" - abode = hass.data[DATA_ABODE] + data = hass.data[ABODE_DOMAIN] - add_devices([AbodeAlarm(abode, abode.get_alarm())]) + alarm_devices = [AbodeAlarm(data, data.abode.get_alarm(), data.name)] + + data.devices.extend(alarm_devices) + + add_devices(alarm_devices) class AbodeAlarm(AbodeDevice, AlarmControlPanel): """An alarm_control_panel implementation for Abode.""" - def __init__(self, controller, device): + def __init__(self, data, device, name): """Initialize the alarm control panel.""" - AbodeDevice.__init__(self, controller, device) - self._name = "{0}".format(DEFAULT_NAME) + super().__init__(data, device) + self._name = name @property def icon(self): @@ -65,6 +69,11 @@ def alarm_arm_away(self, code=None): """Send arm away command.""" self._device.set_away() + @property + def name(self): + """Return the name of the alarm.""" + return self._name or super().name + @property def device_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/alarm_control_panel/arlo.py b/homeassistant/components/alarm_control_panel/arlo.py new file mode 100644 index 00000000000000..2dad3857c4dc18 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/arlo.py @@ -0,0 +1,121 @@ +""" +Support for Arlo Alarm Control Panels. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.arlo/ +""" +import asyncio +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanel, PLATFORM_SCHEMA) +from homeassistant.components.arlo import (DATA_ARLO, CONF_ATTRIBUTION) +from homeassistant.const import ( + ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED) + +_LOGGER = logging.getLogger(__name__) + +ARMED = 'armed' + +CONF_HOME_MODE_NAME = 'home_mode_name' + +DEPENDENCIES = ['arlo'] + +DISARMED = 'disarmed' + +ICON = 'mdi:security' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOME_MODE_NAME, default=ARMED): cv.string, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Arlo Alarm Control Panels.""" + data = hass.data[DATA_ARLO] + + if not data.base_stations: + return + + home_mode_name = config.get(CONF_HOME_MODE_NAME) + base_stations = [] + for base_station in data.base_stations: + base_stations.append(ArloBaseStation(base_station, home_mode_name)) + async_add_devices(base_stations, True) + + +class ArloBaseStation(AlarmControlPanel): + """Representation of an Arlo Alarm Control Panel.""" + + def __init__(self, data, home_mode_name): + """Initialize the alarm control panel.""" + self._base_station = data + self._home_mode_name = home_mode_name + self._state = None + + @property + def icon(self): + """Return icon.""" + return ICON + + @property + def state(self): + """Return the state of the device.""" + return self._state + + def update(self): + """Update the state of the device.""" + # PyArlo sometimes returns None for mode. So retry 3 times before + # returning None. + num_retries = 3 + i = 0 + while i < num_retries: + mode = self._base_station.mode + if mode: + self._state = self._get_state_from_mode(mode) + return + i += 1 + self._state = None + + @asyncio.coroutine + def async_alarm_disarm(self, code=None): + """Send disarm command.""" + self._base_station.mode = DISARMED + + @asyncio.coroutine + def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + self._base_station.mode = ARMED + + @asyncio.coroutine + def async_alarm_arm_home(self, code=None): + """Send arm home command. Uses custom mode.""" + self._base_station.mode = self._home_mode_name + + @property + def name(self): + """Return the name of the base station.""" + return self._base_station.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._base_station.device_id + } + + def _get_state_from_mode(self, mode): + """Convert Arlo mode to Home Assistant state.""" + if mode == ARMED: + return STATE_ALARM_ARMED_AWAY + elif mode == DISARMED: + return STATE_ALARM_DISARMED + elif mode == self._home_mode_name: + return STATE_ALARM_ARMED_HOME + return None diff --git a/homeassistant/components/alarm_control_panel/concord232.py b/homeassistant/components/alarm_control_panel/concord232.py index df815424ee9e09..291d4bc80b59e7 100755 --- a/homeassistant/components/alarm_control_panel/concord232.py +++ b/homeassistant/components/alarm_control_panel/concord232.py @@ -107,7 +107,7 @@ def update(self): newstate = STATE_ALARM_ARMED_AWAY if not newstate == self._state: - _LOGGER.info("State Chnage from %s to %s", self._state, newstate) + _LOGGER.info("State Change from %s to %s", self._state, newstate) self._state = newstate return self._state diff --git a/homeassistant/components/alarm_control_panel/demo.py b/homeassistant/components/alarm_control_panel/demo.py index 8ebf0a93c38aa9..00dae5c2779563 100644 --- a/homeassistant/components/alarm_control_panel/demo.py +++ b/homeassistant/components/alarm_control_panel/demo.py @@ -5,10 +5,26 @@ https://home-assistant.io/components/demo/ """ import homeassistant.components.alarm_control_panel.manual as manual +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_TRIGGERED, CONF_PENDING_TIME) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Demo alarm control panel platform.""" add_devices([ - manual.ManualAlarm(hass, 'Alarm', '1234', 5, 10, False), + manual.ManualAlarm(hass, 'Alarm', '1234', 5, 10, False, { + STATE_ALARM_ARMED_AWAY: { + CONF_PENDING_TIME: 5 + }, + STATE_ALARM_ARMED_HOME: { + CONF_PENDING_TIME: 5 + }, + STATE_ALARM_ARMED_NIGHT: { + CONF_PENDING_TIME: 5 + }, + STATE_ALARM_TRIGGERED: { + CONF_PENDING_TIME: 5 + }, + }), ]) diff --git a/homeassistant/components/alarm_control_panel/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index fbafe061334c01..7719ab884bc909 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -18,13 +18,14 @@ CONF_NAME, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED) -REQUIREMENTS = ['pythonegardia==1.0.20'] +REQUIREMENTS = ['pythonegardia==1.0.22'] _LOGGER = logging.getLogger(__name__) CONF_REPORT_SERVER_CODES = 'report_server_codes' CONF_REPORT_SERVER_ENABLED = 'report_server_enabled' CONF_REPORT_SERVER_PORT = 'report_server_port' +CONF_REPORT_SERVER_CODES_IGNORE = 'ignore' DEFAULT_NAME = 'Egardia' DEFAULT_PORT = 80 @@ -148,9 +149,15 @@ def lookupstatusfromcode(self, statuscode): def parsestatus(self, status): """Parse the status.""" - newstatus = ([v for k, v in STATES.items() - if status.upper() == k][0]) - self._status = newstatus + _LOGGER.debug("Parsing status %s", status) + # Ignore the statuscode if it is IGNORE + if status.lower().strip() != CONF_REPORT_SERVER_CODES_IGNORE: + _LOGGER.debug("Not ignoring status") + newstatus = ([v for k, v in STATES.items() + if status.upper() == k][0]) + self._status = newstatus + else: + _LOGGER.error("Ignoring status") def update(self): """Update the alarm status.""" diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index f345ccc4dcdf15..237959ab10d384 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -4,6 +4,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.manual/ """ +import copy import datetime import logging @@ -24,9 +25,28 @@ DEFAULT_TRIGGER_TIME = 120 DEFAULT_DISARM_AFTER_TRIGGER = False +SUPPORTED_PENDING_STATES = [STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED] + ATTR_POST_PENDING_STATE = 'post_pending_state' -PLATFORM_SCHEMA = vol.Schema({ + +def _state_validator(config): + config = copy.deepcopy(config) + for state in SUPPORTED_PENDING_STATES: + if CONF_PENDING_TIME not in config[state]: + config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME] + + return config + + +STATE_SETTING_SCHEMA = vol.Schema({ + vol.Optional(CONF_PENDING_TIME): + vol.All(vol.Coerce(int), vol.Range(min=0)) +}) + + +PLATFORM_SCHEMA = vol.Schema(vol.All({ vol.Required(CONF_PLATFORM): 'manual', vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, vol.Optional(CONF_CODE): cv.string, @@ -36,7 +56,11 @@ vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional(CONF_DISARM_AFTER_TRIGGER, default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean, -}) + vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_ARMED_HOME, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_TRIGGERED, default={}): STATE_SETTING_SCHEMA, +}, _state_validator)) _LOGGER = logging.getLogger(__name__) @@ -49,7 +73,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): config.get(CONF_CODE), config.get(CONF_PENDING_TIME, DEFAULT_PENDING_TIME), config.get(CONF_TRIGGER_TIME, DEFAULT_TRIGGER_TIME), - config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER) + config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER), + config )]) @@ -63,19 +88,23 @@ class ManualAlarm(alarm.AlarmControlPanel): or disarm if `disarm_after_trigger` is true. """ - def __init__(self, hass, name, code, pending_time, - trigger_time, disarm_after_trigger): + def __init__(self, hass, name, code, pending_time, trigger_time, + disarm_after_trigger, config): """Init the manual alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass self._name = name self._code = str(code) if code else None - self._pending_time = datetime.timedelta(seconds=pending_time) self._trigger_time = datetime.timedelta(seconds=trigger_time) self._disarm_after_trigger = disarm_after_trigger self._pre_trigger_state = self._state self._state_ts = None + self._pending_time_by_state = {} + for state in SUPPORTED_PENDING_STATES: + self._pending_time_by_state[state] = datetime.timedelta( + seconds=config[state][CONF_PENDING_TIME]) + @property def should_poll(self): """Return the plling state.""" @@ -89,17 +118,10 @@ def name(self): @property def state(self): """Return the state of the device.""" - if self._state in (STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_NIGHT) and \ - self._pending_time and self._state_ts + self._pending_time > \ - dt_util.utcnow(): - return STATE_ALARM_PENDING - if self._state == STATE_ALARM_TRIGGERED and self._trigger_time: - if self._state_ts + self._pending_time > dt_util.utcnow(): + if self._within_pending_time(self._state): return STATE_ALARM_PENDING - elif (self._state_ts + self._pending_time + + elif (self._state_ts + self._pending_time_by_state[self._state] + self._trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED @@ -107,8 +129,16 @@ def state(self): self._state = self._pre_trigger_state return self._state + if self._state in SUPPORTED_PENDING_STATES and \ + self._within_pending_time(self._state): + return STATE_ALARM_PENDING + return self._state + def _within_pending_time(self, state): + pending_time = self._pending_time_by_state[state] + return self._state_ts + pending_time > dt_util.utcnow() + @property def code_format(self): """One or more characters.""" @@ -128,58 +158,47 @@ def alarm_arm_home(self, code=None): if not self._validate_code(code, STATE_ALARM_ARMED_HOME): return - self._state = STATE_ALARM_ARMED_HOME - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_HOME) def alarm_arm_away(self, code=None): """Send arm away command.""" if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): return - self._state = STATE_ALARM_ARMED_AWAY - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_AWAY) def alarm_arm_night(self, code=None): """Send arm night command.""" if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT): return - self._state = STATE_ALARM_ARMED_NIGHT - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_NIGHT) def alarm_trigger(self, code=None): """Send alarm trigger command. No code needed.""" self._pre_trigger_state = self._state - self._state = STATE_ALARM_TRIGGERED + + self._update_state(STATE_ALARM_TRIGGERED) + + def _update_state(self, state): + self._state = state self._state_ts = dt_util.utcnow() self.schedule_update_ha_state() - if self._trigger_time: + pending_time = self._pending_time_by_state[state] + + if state == STATE_ALARM_TRIGGERED and self._trigger_time: track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._state_ts + pending_time) track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time + self._trigger_time) + self._state_ts + self._trigger_time + pending_time) + elif state in SUPPORTED_PENDING_STATES and pending_time: + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + pending_time) def _validate_code(self, code, state): """Validate given code.""" diff --git a/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant/components/alarm_control_panel/manual_mqtt.py index b554a667b2a0c2..44247616b59ff3 100644 --- a/homeassistant/components/alarm_control_panel/manual_mqtt.py +++ b/homeassistant/components/alarm_control_panel/manual_mqtt.py @@ -5,6 +5,7 @@ https://home-assistant.io/components/alarm_control_panel.manual_mqtt/ """ import asyncio +import copy import datetime import logging @@ -13,9 +14,9 @@ import homeassistant.components.alarm_control_panel as alarm import homeassistant.util.dt as dt_util from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, CONF_PLATFORM, - CONF_NAME, CONF_CODE, CONF_PENDING_TIME, CONF_TRIGGER_TIME, + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, + CONF_PLATFORM, CONF_NAME, CONF_CODE, CONF_PENDING_TIME, CONF_TRIGGER_TIME, CONF_DISARM_AFTER_TRIGGER) import homeassistant.components.mqtt as mqtt @@ -28,6 +29,7 @@ CONF_PAYLOAD_DISARM = 'payload_disarm' CONF_PAYLOAD_ARM_HOME = 'payload_arm_home' CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away' +CONF_PAYLOAD_ARM_NIGHT = 'payload_arm_night' DEFAULT_ALARM_NAME = 'HA Alarm' DEFAULT_PENDING_TIME = 60 @@ -35,11 +37,32 @@ DEFAULT_DISARM_AFTER_TRIGGER = False DEFAULT_ARM_AWAY = 'ARM_AWAY' DEFAULT_ARM_HOME = 'ARM_HOME' +DEFAULT_ARM_NIGHT = 'ARM_NIGHT' DEFAULT_DISARM = 'DISARM' +SUPPORTED_PENDING_STATES = [STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED] + +ATTR_POST_PENDING_STATE = 'post_pending_state' + + +def _state_validator(config): + config = copy.deepcopy(config) + for state in SUPPORTED_PENDING_STATES: + if CONF_PENDING_TIME not in config[state]: + config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME] + + return config + + +STATE_SETTING_SCHEMA = vol.Schema({ + vol.Optional(CONF_PENDING_TIME): + vol.All(vol.Coerce(int), vol.Range(min=0)) +}) + DEPENDENCIES = ['mqtt'] -PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ +PLATFORM_SCHEMA = vol.Schema(vol.All(mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Required(CONF_PLATFORM): 'manual_mqtt', vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, vol.Optional(CONF_CODE): cv.string, @@ -49,12 +72,17 @@ vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional(CONF_DISARM_AFTER_TRIGGER, default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean, + vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_ARMED_HOME, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_TRIGGERED, default={}): STATE_SETTING_SCHEMA, vol.Required(mqtt.CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Required(mqtt.CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string, + vol.Optional(CONF_PAYLOAD_ARM_NIGHT, default=DEFAULT_ARM_NIGHT): cv.string, vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string, -}) +}), _state_validator)) _LOGGER = logging.getLogger(__name__) @@ -73,7 +101,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): config.get(mqtt.CONF_QOS), config.get(CONF_PAYLOAD_DISARM), config.get(CONF_PAYLOAD_ARM_HOME), - config.get(CONF_PAYLOAD_ARM_AWAY))]) + config.get(CONF_PAYLOAD_ARM_AWAY), + config.get(CONF_PAYLOAD_ARM_NIGHT), + config)]) class ManualMQTTAlarm(alarm.AlarmControlPanel): @@ -89,7 +119,8 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): def __init__(self, hass, name, code, pending_time, trigger_time, disarm_after_trigger, state_topic, command_topic, qos, - payload_disarm, payload_arm_home, payload_arm_away): + payload_disarm, payload_arm_home, payload_arm_away, + payload_arm_night, config): """Init the manual MQTT alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass @@ -101,12 +132,18 @@ def __init__(self, hass, name, code, pending_time, self._pre_trigger_state = self._state self._state_ts = None + self._pending_time_by_state = {} + for state in SUPPORTED_PENDING_STATES: + self._pending_time_by_state[state] = datetime.timedelta( + seconds=config[state][CONF_PENDING_TIME]) + self._state_topic = state_topic self._command_topic = command_topic self._qos = qos self._payload_disarm = payload_disarm self._payload_arm_home = payload_arm_home self._payload_arm_away = payload_arm_away + self._payload_arm_night = payload_arm_night @property def should_poll(self): @@ -121,23 +158,27 @@ def name(self): @property def state(self): """Return the state of the device.""" - if self._state in (STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_AWAY) and \ - self._pending_time and self._state_ts + self._pending_time > \ - dt_util.utcnow(): - return STATE_ALARM_PENDING - if self._state == STATE_ALARM_TRIGGERED and self._trigger_time: - if self._state_ts + self._pending_time > dt_util.utcnow(): + if self._within_pending_time(self._state): return STATE_ALARM_PENDING - elif (self._state_ts + self._pending_time + + elif (self._state_ts + self._pending_time_by_state[self._state] + self._trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED - return self._pre_trigger_state + else: + self._state = self._pre_trigger_state + return self._state + + if self._state in SUPPORTED_PENDING_STATES and \ + self._within_pending_time(self._state): + return STATE_ALARM_PENDING return self._state + def _within_pending_time(self, state): + pending_time = self._pending_time_by_state[state] + return self._state_ts + pending_time > dt_util.utcnow() + @property def code_format(self): """One or more characters.""" @@ -157,44 +198,47 @@ def alarm_arm_home(self, code=None): if not self._validate_code(code, STATE_ALARM_ARMED_HOME): return - self._state = STATE_ALARM_ARMED_HOME - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_HOME) def alarm_arm_away(self, code=None): """Send arm away command.""" if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): return - self._state = STATE_ALARM_ARMED_AWAY - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() + self._update_state(STATE_ALARM_ARMED_AWAY) - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + def alarm_arm_night(self, code=None): + """Send arm night command.""" + if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT): + return + + self._update_state(STATE_ALARM_ARMED_NIGHT) def alarm_trigger(self, code=None): """Send alarm trigger command. No code needed.""" self._pre_trigger_state = self._state - self._state = STATE_ALARM_TRIGGERED + + self._update_state(STATE_ALARM_TRIGGERED) + + def _update_state(self, state): + self._state = state self._state_ts = dt_util.utcnow() self.schedule_update_ha_state() - if self._trigger_time: + pending_time = self._pending_time_by_state[state] + + if state == STATE_ALARM_TRIGGERED and self._trigger_time: track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._state_ts + pending_time) track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time + self._trigger_time) + self._state_ts + self._trigger_time + pending_time) + elif state in SUPPORTED_PENDING_STATES and pending_time: + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + pending_time) def _validate_code(self, code, state): """Validate given code.""" @@ -203,6 +247,16 @@ def _validate_code(self, code, state): _LOGGER.warning("Invalid code given for %s", state) return check + @property + def device_state_attributes(self): + """Return the state attributes.""" + state_attr = {} + + if self.state == STATE_ALARM_PENDING: + state_attr[ATTR_POST_PENDING_STATE] = self._state + + return state_attr + def async_added_to_hass(self): """Subscribe mqtt events. @@ -221,6 +275,8 @@ def message_received(topic, payload, qos): self.async_alarm_arm_home(self._code) elif payload == self._payload_arm_away: self.async_alarm_arm_away(self._code) + elif payload == self._payload_arm_night: + self.async_alarm_arm_night(self._code) else: _LOGGER.warning("Received unexpected payload: %s", payload) return diff --git a/homeassistant/components/alarm_control_panel/satel_integra.py b/homeassistant/components/alarm_control_panel/satel_integra.py new file mode 100644 index 00000000000000..6115311f873287 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/satel_integra.py @@ -0,0 +1,94 @@ +""" +Support for Satel Integra alarm, using ETHM module: https://www.satel.pl/en/ . + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.satel_integra/ +""" +import asyncio +import logging + +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.satel_integra import (CONF_ARM_HOME_MODE, + DATA_SATEL, + SIGNAL_PANEL_MESSAGE) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['satel_integra'] + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up for AlarmDecoder alarm panels.""" + if not discovery_info: + return + + device = SatelIntegraAlarmPanel("Alarm Panel", + discovery_info.get(CONF_ARM_HOME_MODE)) + async_add_devices([device]) + + +class SatelIntegraAlarmPanel(alarm.AlarmControlPanel): + """Representation of an AlarmDecoder-based alarm panel.""" + + def __init__(self, name, arm_home_mode): + """Initialize the alarm panel.""" + self._name = name + self._state = None + self._arm_home_mode = arm_home_mode + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_PANEL_MESSAGE, self._message_callback) + + @callback + def _message_callback(self, message): + + if message != self._state: + self._state = message + self.async_schedule_update_ha_state() + else: + _LOGGER.warning("Ignoring alarm status message, same state") + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def code_format(self): + """Return the regex for code format or None if no code is required.""" + return '^\\d{4,6}$' + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @asyncio.coroutine + def async_alarm_disarm(self, code=None): + """Send disarm command.""" + if code: + yield from self.hass.data[DATA_SATEL].disarm(code) + + @asyncio.coroutine + def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + if code: + yield from self.hass.data[DATA_SATEL].arm(code) + + @asyncio.coroutine + def async_alarm_arm_home(self, code=None): + """Send arm home command.""" + if code: + yield from self.hass.data[DATA_SATEL].arm(code, + self._arm_home_mode) diff --git a/homeassistant/components/alarm_control_panel/spc.py b/homeassistant/components/alarm_control_panel/spc.py index de4d5098b41cee..1682ef2ae02629 100644 --- a/homeassistant/components/alarm_control_panel/spc.py +++ b/homeassistant/components/alarm_control_panel/spc.py @@ -27,20 +27,20 @@ def _get_alarm_state(spc_mode): @asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the SPC alarm control panel platform.""" if (discovery_info is None or discovery_info[ATTR_DISCOVER_AREAS] is None): return - entities = [SpcAlarm(hass=hass, - area_id=area['id'], - name=area['name'], - state=_get_alarm_state(area['mode'])) - for area in discovery_info[ATTR_DISCOVER_AREAS]] + devices = [SpcAlarm(hass=hass, + area_id=area['id'], + name=area['name'], + state=_get_alarm_state(area['mode'])) + for area in discovery_info[ATTR_DISCOVER_AREAS]] - async_add_entities(entities) + async_add_devices(devices) class SpcAlarm(alarm.AlarmControlPanel): diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py new file mode 100644 index 00000000000000..61db142ac422ff --- /dev/null +++ b/homeassistant/components/alexa/smart_home.py @@ -0,0 +1,203 @@ +"""Support for alexa Smart Home Skill API.""" +import asyncio +import logging +from uuid import uuid4 + +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) +from homeassistant.components import switch, light +from homeassistant.util.decorator import Registry + +HANDLERS = Registry() +_LOGGER = logging.getLogger(__name__) + +API_DIRECTIVE = 'directive' +API_EVENT = 'event' +API_HEADER = 'header' +API_PAYLOAD = 'payload' +API_ENDPOINT = 'endpoint' + + +MAPPING_COMPONENT = { + switch.DOMAIN: ['SWITCH', ('Alexa.PowerController',), None], + light.DOMAIN: [ + 'LIGHT', ('Alexa.PowerController',), { + light.SUPPORT_BRIGHTNESS: 'Alexa.BrightnessController' + } + ], +} + + +@asyncio.coroutine +def async_handle_message(hass, message): + """Handle incoming API messages.""" + assert message[API_DIRECTIVE][API_HEADER]['payloadVersion'] == '3' + + # Read head data + message = message[API_DIRECTIVE] + namespace = message[API_HEADER]['namespace'] + name = message[API_HEADER]['name'] + + # Do we support this API request? + funct_ref = HANDLERS.get((namespace, name)) + if not funct_ref: + _LOGGER.warning( + "Unsupported API request %s/%s", namespace, name) + return api_error(message) + + return (yield from funct_ref(hass, message)) + + +def api_message(request, name='Response', namespace='Alexa', payload=None): + """Create a API formatted response message. + + Async friendly. + """ + payload = payload or {} + + response = { + API_EVENT: { + API_HEADER: { + 'namespace': namespace, + 'name': name, + 'messageId': str(uuid4()), + 'payloadVersion': '3', + }, + API_PAYLOAD: payload, + } + } + + # If a correlation token exsits, add it to header / Need by Async requests + token = request[API_HEADER].get('correlationToken') + if token: + response[API_EVENT][API_HEADER]['correlationToken'] = token + + # Extend event with endpoint object / Need by Async requests + if API_ENDPOINT in request: + response[API_EVENT][API_ENDPOINT] = request[API_ENDPOINT].copy() + + return response + + +def api_error(request, error_type='INTERNAL_ERROR', error_message=""): + """Create a API formatted error response. + + Async friendly. + """ + payload = { + 'type': error_type, + 'message': error_message, + } + + return api_message(request, name='ErrorResponse', payload=payload) + + +@HANDLERS.register(('Alexa.Discovery', 'Discover')) +@asyncio.coroutine +def async_api_discovery(hass, request): + """Create a API formatted discovery response. + + Async friendly. + """ + discovery_endpoints = [] + + for entity in hass.states.async_all(): + class_data = MAPPING_COMPONENT.get(entity.domain) + + if not class_data: + continue + + endpoint = { + 'displayCategories': [class_data[0]], + 'additionalApplianceDetails': {}, + 'endpointId': entity.entity_id.replace('.', '#'), + 'friendlyName': entity.name, + 'description': '', + 'manufacturerName': 'Unknown', + } + actions = set() + + # static actions + if class_data[1]: + actions |= set(class_data[1]) + + # dynamic actions + if class_data[2]: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + for feature, action_name in class_data[2].items(): + if feature & supported > 0: + actions.add(action_name) + + # Write action into capabilities + capabilities = [] + for action in actions: + capabilities.append({ + 'type': 'AlexaInterface', + 'interface': action, + 'version': 3, + }) + + endpoint['capabilities'] = capabilities + discovery_endpoints.append(endpoint) + + return api_message( + request, name='Discover.Response', namespace='Alexa.Discovery', + payload={'endpoints': discovery_endpoints}) + + +def extract_entity(funct): + """Decorator for extract entity object from request.""" + @asyncio.coroutine + def async_api_entity_wrapper(hass, request): + """Process a turn on request.""" + entity_id = request[API_ENDPOINT]['endpointId'].replace('#', '.') + + # extract state object + entity = hass.states.get(entity_id) + if not entity: + _LOGGER.error("Can't process %s for %s", + request[API_HEADER]['name'], entity_id) + return api_error(request, error_type='NO_SUCH_ENDPOINT') + + return (yield from funct(hass, request, entity)) + + return async_api_entity_wrapper + + +@HANDLERS.register(('Alexa.PowerController', 'TurnOn')) +@extract_entity +@asyncio.coroutine +def async_api_turn_on(hass, request, entity): + """Process a turn on request.""" + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PowerController', 'TurnOff')) +@extract_entity +@asyncio.coroutine +def async_api_turn_off(hass, request, entity): + """Process a turn off request.""" + yield from hass.services.async_call(entity.domain, SERVICE_TURN_OFF, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness')) +@extract_entity +@asyncio.coroutine +def async_api_set_brightness(hass, request, entity): + """Process a set brightness request.""" + brightness = request[API_PAYLOAD]['brightness'] + + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_BRIGHTNESS: brightness, + }, blocking=True) + + return api_message(request) diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index c22683970bf909..3b905ab04205d9 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -13,7 +13,7 @@ import homeassistant.core as ha import homeassistant.remote as rem -from homeassistant.bootstrap import ERROR_LOG_FILENAME +from homeassistant.bootstrap import DATA_LOGGING from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_NOT_FOUND, @@ -51,8 +51,9 @@ def setup(hass, config): hass.http.register_view(APIComponentsView) hass.http.register_view(APITemplateView) - hass.http.register_static_path( - URL_API_ERROR_LOG, hass.config.path(ERROR_LOG_FILENAME), False) + log_path = hass.data.get(DATA_LOGGING, None) + if log_path: + hass.http.register_static_path(URL_API_ERROR_LOG, log_path, False) return True diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index 4fce508ba7e54a..5e02f80f229dfa 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -18,7 +18,7 @@ from homeassistant.components.discovery import SERVICE_APPLE_TV import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyatv==0.3.4'] +REQUIREMENTS = ['pyatv==0.3.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py index 1ba2acb4fe073c..f3397a884d17dd 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -1,5 +1,5 @@ """ -This component provides basic support for Netgear Arlo IP cameras. +This component provides support for Netgear Arlo IP cameras. For more details about this component, please refer to the documentation at https://home-assistant.io/components/arlo/ @@ -12,7 +12,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.const import CONF_USERNAME, CONF_PASSWORD -REQUIREMENTS = ['pyarlo==0.0.4'] +REQUIREMENTS = ['pyarlo==0.0.7'] _LOGGER = logging.getLogger(__name__) @@ -23,7 +23,7 @@ DOMAIN = 'arlo' NOTIFICATION_ID = 'arlo_notification' -NOTIFICATION_TITLE = 'Arlo Camera Setup' +NOTIFICATION_TITLE = 'Arlo Component Setup' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py index 32d2d245bef244..90baeaded141bf 100644 --- a/homeassistant/components/automation/event.py +++ b/homeassistant/components/automation/event.py @@ -21,7 +21,7 @@ TRIGGER_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'event', vol.Required(CONF_EVENT_TYPE): cv.string, - vol.Optional(CONF_EVENT_DATA): dict, + vol.Optional(CONF_EVENT_DATA, default={}): dict, }) @@ -29,18 +29,24 @@ def async_trigger(hass, config, action): """Listen for events based on configuration.""" event_type = config.get(CONF_EVENT_TYPE) - event_data = config.get(CONF_EVENT_DATA) + event_data_schema = vol.Schema( + config.get(CONF_EVENT_DATA), + extra=vol.ALLOW_EXTRA) @callback def handle_event(event): """Listen for events and calls the action when data matches.""" - if not event_data or all(val == event.data.get(key) for key, val - in event_data.items()): - hass.async_run_job(action, { - 'trigger': { - 'platform': 'event', - 'event': event, - }, - }) + try: + event_data_schema(event.data) + except vol.Invalid: + # If event data doesn't match requested schema, skip event + return + + hass.async_run_job(action, { + 'trigger': { + 'platform': 'event', + 'event': event, + }, + }) return hass.bus.async_listen(event_type, handle_event) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 51b2ea89f0f1c5..571888038a63e7 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -99,8 +99,8 @@ def call_action(): return async_remove_track_same = async_track_same_state( - hass, True, time_delta, call_action, entity_ids=entity_id, - async_check_func=check_numeric_state) + hass, time_delta, call_action, entity_ids=entity_id, + async_check_same_func=check_numeric_state) unsub = async_track_state_change( hass, entity_id, state_automation_listener) diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index e7a01cb711582e..7ed44761be80fe 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -65,7 +65,9 @@ def call_action(): return async_remove_track_same = async_track_same_state( - hass, to_s.state, time_delta, call_action, entity_ids=entity_id) + hass, time_delta, call_action, + lambda _, _2, to_state: to_state.state == to_s.state, + entity_ids=entity_id) unsub = async_track_state_change( hass, entity_id, state_automation_listener, from_state, to_state) diff --git a/homeassistant/components/axis.py b/homeassistant/components/axis.py index eaf859376582cc..aee8dbc415b3e2 100644 --- a/homeassistant/components/axis.py +++ b/homeassistant/components/axis.py @@ -14,7 +14,7 @@ from homeassistant.config import load_yaml_config_file from homeassistant.const import (ATTR_LOCATION, ATTR_TRIPPED, CONF_HOST, CONF_INCLUDE, CONF_NAME, - CONF_PASSWORD, CONF_TRIGGER_TIME, + CONF_PASSWORD, CONF_PORT, CONF_TRIGGER_TIME, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP) from homeassistant.components.discovery import SERVICE_AXIS from homeassistant.helpers import config_validation as cv @@ -23,7 +23,7 @@ from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['axis==8'] +REQUIREMENTS = ['axis==12'] _LOGGER = logging.getLogger(__name__) @@ -51,6 +51,7 @@ vol.Optional(CONF_USERNAME, default=AXIS_DEFAULT_USERNAME): cv.string, vol.Optional(CONF_PASSWORD, default=AXIS_DEFAULT_PASSWORD): cv.string, vol.Optional(CONF_TRIGGER_TIME, default=0): cv.positive_int, + vol.Optional(CONF_PORT, default=80): cv.positive_int, vol.Optional(ATTR_LOCATION, default=''): cv.string, }) @@ -76,7 +77,7 @@ }) -def request_configuration(hass, name, host, serialnumber): +def request_configuration(hass, config, name, host, serialnumber): """Request configuration steps from the user.""" configurator = hass.components.configurator @@ -91,15 +92,15 @@ def configuration_callback(callback_data): if CONF_NAME not in callback_data: callback_data[CONF_NAME] = name try: - config = DEVICE_SCHEMA(callback_data) + device_config = DEVICE_SCHEMA(callback_data) except vol.Invalid: configurator.notify_errors(request_id, "Bad input, please check spelling.") return False - if setup_device(hass, config): + if setup_device(hass, config, device_config): config_file = _read_config(hass) - config_file[serialnumber] = dict(config) + config_file[serialnumber] = dict(device_config) del config_file[serialnumber]['hass'] _write_config(hass, config_file) configurator.request_done(request_id) @@ -132,6 +133,9 @@ def configuration_callback(callback_data): {'id': ATTR_LOCATION, 'name': "Physical location of device (optional)", 'type': 'text'}, + {'id': CONF_PORT, + 'name': "HTTP port (default=80)", + 'type': 'number'}, {'id': CONF_TRIGGER_TIME, 'name': "Sensor update interval (optional)", 'type': 'number'}, @@ -139,7 +143,7 @@ def configuration_callback(callback_data): ) -def setup(hass, base_config): +def setup(hass, config): """Common setup for Axis devices.""" def _shutdown(call): # pylint: disable=unused-argument """Stop the metadatastream on shutdown.""" @@ -160,16 +164,17 @@ def axis_device_discovered(service, discovery_info): if serialnumber in config_file: # Device config saved to file try: - config = DEVICE_SCHEMA(config_file[serialnumber]) - config[CONF_HOST] = host + device_config = DEVICE_SCHEMA(config_file[serialnumber]) + device_config[CONF_HOST] = host except vol.Invalid as err: _LOGGER.error("Bad data from %s. %s", CONFIG_FILE, err) return False - if not setup_device(hass, config): - _LOGGER.error("Couldn\'t set up %s", config[CONF_NAME]) + if not setup_device(hass, config, device_config): + _LOGGER.error("Couldn\'t set up %s", + device_config[CONF_NAME]) else: # New device, create configuration request for UI - request_configuration(hass, name, host, serialnumber) + request_configuration(hass, config, name, host, serialnumber) else: # Device already registered, but on a different IP device = AXIS_DEVICES[serialnumber] @@ -181,13 +186,13 @@ def axis_device_discovered(service, discovery_info): # Register discovery service discovery.listen(hass, SERVICE_AXIS, axis_device_discovered) - if DOMAIN in base_config: - for device in base_config[DOMAIN]: - config = base_config[DOMAIN][device] - if CONF_NAME not in config: - config[CONF_NAME] = device - if not setup_device(hass, config): - _LOGGER.error("Couldn\'t set up %s", config[CONF_NAME]) + if DOMAIN in config: + for device in config[DOMAIN]: + device_config = config[DOMAIN][device] + if CONF_NAME not in device_config: + device_config[CONF_NAME] = device + if not setup_device(hass, config, device_config): + _LOGGER.error("Couldn\'t set up %s", device_config[CONF_NAME]) # Services to communicate with device. descriptions = load_yaml_config_file( @@ -215,20 +220,20 @@ def vapix_service(call): return True -def setup_device(hass, config): +def setup_device(hass, config, device_config): """Set up device.""" from axis import AxisDevice - config['hass'] = hass - device = AxisDevice(config) # Initialize device + device_config['hass'] = hass + device = AxisDevice(device_config) # Initialize device enable_metadatastream = False if device.serial_number is None: # If there is no serial number a connection could not be made - _LOGGER.error("Couldn\'t connect to %s", config[CONF_HOST]) + _LOGGER.error("Couldn\'t connect to %s", device_config[CONF_HOST]) return False - for component in config[CONF_INCLUDE]: + for component in device_config[CONF_INCLUDE]: if component in EVENT_TYPES: # Sensors are created by device calling event_initialized # when receiving initialize messages on metadatastream @@ -236,7 +241,18 @@ def setup_device(hass, config): if not enable_metadatastream: enable_metadatastream = True else: - discovery.load_platform(hass, component, DOMAIN, config) + camera_config = { + CONF_HOST: device_config[CONF_HOST], + CONF_NAME: device_config[CONF_NAME], + CONF_PORT: device_config[CONF_PORT], + CONF_USERNAME: device_config[CONF_USERNAME], + CONF_PASSWORD: device_config[CONF_PASSWORD] + } + discovery.load_platform(hass, + component, + DOMAIN, + camera_config, + config) if enable_metadatastream: device.initialize_new_event = event_initialized diff --git a/homeassistant/components/binary_sensor/abode.py b/homeassistant/components/binary_sensor/abode.py index d3b0d662a9466d..8ad401589584ec 100644 --- a/homeassistant/components/binary_sensor/abode.py +++ b/homeassistant/components/binary_sensor/abode.py @@ -6,7 +6,8 @@ """ import logging -from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.abode import (AbodeDevice, AbodeAutomation, + DOMAIN as ABODE_DOMAIN) from homeassistant.components.binary_sensor import BinarySensorDevice @@ -17,39 +18,38 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a sensor for an Abode device.""" - abode = hass.data[DATA_ABODE] + import abodepy.helpers.constants as CONST + import abodepy.helpers.timeline as TIMELINE - device_types = map_abode_device_class().keys() + data = hass.data[ABODE_DOMAIN] - sensors = [] - for sensor in abode.get_devices(type_filter=device_types): - sensors.append(AbodeBinarySensor(abode, sensor)) + device_types = [CONST.TYPE_CONNECTIVITY, CONST.TYPE_MOISTURE, + CONST.TYPE_MOTION, CONST.TYPE_OCCUPANCY, + CONST.TYPE_OPENING] - add_devices(sensors) + devices = [] + for device in data.abode.get_devices(generic_type=device_types): + if data.is_excluded(device): + continue + devices.append(AbodeBinarySensor(data, device)) -def map_abode_device_class(): - """Map Abode device types to Home Assistant binary sensor class.""" - import abodepy.helpers.constants as CONST + for automation in data.abode.get_automations( + generic_type=CONST.TYPE_QUICK_ACTION): + if data.is_automation_excluded(automation): + continue + + devices.append(AbodeQuickActionBinarySensor( + data, automation, TIMELINE.AUTOMATION_EDIT_GROUP)) - return { - CONST.DEVICE_GLASS_BREAK: 'connectivity', - CONST.DEVICE_KEYPAD: 'connectivity', - CONST.DEVICE_DOOR_CONTACT: 'opening', - CONST.DEVICE_STATUS_DISPLAY: 'connectivity', - CONST.DEVICE_MOTION_CAMERA: 'connectivity', - CONST.DEVICE_WATER_SENSOR: 'moisture' - } + data.devices.extend(devices) + + add_devices(devices) class AbodeBinarySensor(AbodeDevice, BinarySensorDevice): """A binary sensor implementation for Abode device.""" - def __init__(self, controller, device): - """Initialize a sensor for Abode device.""" - AbodeDevice.__init__(self, controller, device) - self._device_class = map_abode_device_class().get(self._device.type) - @property def is_on(self): """Return True if the binary sensor is on.""" @@ -58,4 +58,17 @@ def is_on(self): @property def device_class(self): """Return the class of the binary sensor.""" - return self._device_class + return self._device.generic_type + + +class AbodeQuickActionBinarySensor(AbodeAutomation, BinarySensorDevice): + """A binary sensor implementation for Abode quick action automations.""" + + def trigger(self): + """Trigger a quick automation.""" + self._automation.trigger() + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._automation.is_active diff --git a/homeassistant/components/binary_sensor/doorbird.py b/homeassistant/components/binary_sensor/doorbird.py new file mode 100644 index 00000000000000..9a13687fc54e49 --- /dev/null +++ b/homeassistant/components/binary_sensor/doorbird.py @@ -0,0 +1,60 @@ +"""Support for reading binary states from a DoorBird video doorbell.""" +from datetime import timedelta +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.doorbird import DOMAIN as DOORBIRD_DOMAIN +from homeassistant.util import Throttle + +DEPENDENCIES = ['doorbird'] + +_LOGGER = logging.getLogger(__name__) +_MIN_UPDATE_INTERVAL = timedelta(milliseconds=250) + +SENSOR_TYPES = { + "doorbell": { + "name": "Doorbell Ringing", + "icon": { + True: "bell-ring", + False: "bell", + None: "bell-outline" + } + } +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the DoorBird binary sensor component.""" + device = hass.data.get(DOORBIRD_DOMAIN) + add_devices([DoorBirdBinarySensor(device, "doorbell")], True) + + +class DoorBirdBinarySensor(BinarySensorDevice): + """A binary sensor of a DoorBird device.""" + + def __init__(self, device, sensor_type): + """Initialize a binary sensor on a DoorBird device.""" + self._device = device + self._sensor_type = sensor_type + self._state = None + + @property + def name(self): + """Get the name of the sensor.""" + return SENSOR_TYPES[self._sensor_type]["name"] + + @property + def icon(self): + """Get an icon to display.""" + state_icon = SENSOR_TYPES[self._sensor_type]["icon"][self._state] + return "mdi:{}".format(state_icon) + + @property + def is_on(self): + """Get the state of the binary sensor.""" + return self._state + + @Throttle(_MIN_UPDATE_INTERVAL) + def update(self): + """Pull the latest value from the device.""" + self._state = self._device.doorbell_state() diff --git a/homeassistant/components/binary_sensor/insteon_plm.py b/homeassistant/components/binary_sensor/insteon_plm.py index 448ceae8636c8d..0702ce8bb9e07e 100644 --- a/homeassistant/components/binary_sensor/insteon_plm.py +++ b/homeassistant/components/binary_sensor/insteon_plm.py @@ -55,12 +55,12 @@ def should_poll(self): @property def address(self): - """Return the the address of the node.""" + """Return the address of the node.""" return self._address @property def name(self): - """Return the the name of the node.""" + """Return the name of the node.""" return self._name @property diff --git a/homeassistant/components/binary_sensor/iss.py b/homeassistant/components/binary_sensor/iss.py index 3b927853c009f2..d35c36a012e940 100644 --- a/homeassistant/components/binary_sensor/iss.py +++ b/homeassistant/components/binary_sensor/iss.py @@ -13,7 +13,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.binary_sensor import ( BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.const import (CONF_NAME, ATTR_LONGITUDE, ATTR_LATITUDE) +from homeassistant.const import ( + CONF_NAME, ATTR_LONGITUDE, ATTR_LATITUDE, CONF_SHOW_ON_MAP) from homeassistant.util import Throttle REQUIREMENTS = ['pyiss==1.0.1'] @@ -23,8 +24,6 @@ ATTR_ISS_NEXT_RISE = 'next_rise' ATTR_ISS_NUMBER_PEOPLE_SPACE = 'number_of_people_in_space' -CONF_SHOW_ON_MAP = 'show_on_map' - DEFAULT_NAME = 'ISS' DEFAULT_DEVICE_CLASS = 'visible' diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index 2b11c3fe172eef..406f60f99bb93f 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -53,7 +53,7 @@ @asyncio.coroutine -def async_setup_platform(hass, config, add_devices, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up binary sensor(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -61,25 +61,25 @@ def async_setup_platform(hass, config, add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) + async_add_devices_config(hass, config, async_add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up binary sensors for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXBinarySensor(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): +def async_add_devices_config(hass, config, async_add_devices): """Set up binary senor for KNX platform configured within plattform.""" name = config.get(CONF_NAME) import xknx @@ -101,7 +101,7 @@ def async_add_devices_config(hass, config, add_devices): entity.automations.append(KNXAutomation( hass=hass, device=binary_sensor, hook=hook, action=action, counter=counter)) - add_devices([entity]) + async_add_devices([entity]) class KNXBinarySensor(BinarySensorDevice): diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index 7d40544d601b2b..c5fba72bde0e06 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -16,14 +16,21 @@ from homeassistant.const import ( CONF_NAME, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF, CONF_DEVICE_CLASS) -from homeassistant.components.mqtt import (CONF_STATE_TOPIC, CONF_QOS) +from homeassistant.components.mqtt import ( + CONF_STATE_TOPIC, CONF_AVAILABILITY_TOPIC, CONF_QOS, valid_subscribe_topic) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +CONF_PAYLOAD_AVAILABLE = 'payload_available' +CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' + DEFAULT_NAME = 'MQTT Binary sensor' DEFAULT_PAYLOAD_OFF = 'OFF' DEFAULT_PAYLOAD_ON = 'ON' +DEFAULT_PAYLOAD_AVAILABLE = 'online' +DEFAULT_PAYLOAD_NOT_AVAILABLE = 'offline' + DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ @@ -31,6 +38,11 @@ vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_AVAILABILITY_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_PAYLOAD_AVAILABLE, + default=DEFAULT_PAYLOAD_AVAILABLE): cv.string, + vol.Optional(CONF_PAYLOAD_NOT_AVAILABLE, + default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string, }) @@ -47,10 +59,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): async_add_devices([MqttBinarySensor( config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), + config.get(CONF_AVAILABILITY_TOPIC), config.get(CONF_DEVICE_CLASS), config.get(CONF_QOS), config.get(CONF_PAYLOAD_ON), config.get(CONF_PAYLOAD_OFF), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), value_template )]) @@ -58,15 +73,20 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class MqttBinarySensor(BinarySensorDevice): """Representation a binary sensor that is updated by MQTT.""" - def __init__(self, name, state_topic, device_class, qos, payload_on, - payload_off, value_template): + def __init__(self, name, state_topic, availability_topic, device_class, + qos, payload_on, payload_off, payload_available, + payload_not_available, value_template): """Initialize the MQTT binary sensor.""" self._name = name - self._state = False + self._state = None self._state_topic = state_topic + self._availability_topic = availability_topic + self._available = True if availability_topic is None else False self._device_class = device_class self._payload_on = payload_on self._payload_off = payload_off + self._payload_available = payload_available + self._payload_not_available = payload_not_available self._qos = qos self._template = value_template @@ -76,8 +96,8 @@ def async_added_to_hass(self): This method must be run in the event loop and returns a coroutine. """ @callback - def message_received(topic, payload, qos): - """Handle a new received MQTT message.""" + def state_message_received(topic, payload, qos): + """Handle a new received MQTT state message.""" if self._template is not None: payload = self._template.async_render_with_possible_json_value( payload) @@ -88,8 +108,23 @@ def message_received(topic, payload, qos): self.async_schedule_update_ha_state() - return mqtt.async_subscribe( - self.hass, self._state_topic, message_received, self._qos) + yield from mqtt.async_subscribe( + self.hass, self._state_topic, state_message_received, self._qos) + + @callback + def availability_message_received(topic, payload, qos): + """Handle a new received MQTT availability message.""" + if payload == self._payload_available: + self._available = True + elif payload == self._payload_not_available: + self._available = False + + self.async_schedule_update_ha_state() + + if self._availability_topic is not None: + yield from mqtt.async_subscribe( + self.hass, self._availability_topic, + availability_message_received, self._qos) @property def should_poll(self): @@ -101,6 +136,11 @@ def name(self): """Return the name of the binary sensor.""" return self._name + @property + def available(self) -> bool: + """Return if the binary sensor is available.""" + return self._available + @property def is_on(self): """Return true if the binary sensor is on.""" diff --git a/homeassistant/components/binary_sensor/netatmo.py b/homeassistant/components/binary_sensor/netatmo.py index 13b9fc1f0051ec..e597f1d0bbed0e 100644 --- a/homeassistant/components/binary_sensor/netatmo.py +++ b/homeassistant/components/binary_sensor/netatmo.py @@ -14,7 +14,7 @@ BinarySensorDevice, PLATFORM_SCHEMA) from homeassistant.components.netatmo import CameraData from homeassistant.loader import get_component -from homeassistant.const import CONF_TIMEOUT, CONF_OFFSET +from homeassistant.const import CONF_TIMEOUT from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -44,14 +44,12 @@ CONF_PRESENCE_SENSORS = 'presence_sensors' CONF_TAG_SENSORS = 'tag_sensors' -DEFAULT_TIMEOUT = 15 -DEFAULT_OFFSET = 90 +DEFAULT_TIMEOUT = 90 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_CAMERAS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_HOME): cv.string, - vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): cv.positive_int, vol.Optional(CONF_PRESENCE_SENSORS, default=PRESENCE_SENSOR_TYPES): vol.All(cv.ensure_list, [vol.In(PRESENCE_SENSOR_TYPES)]), vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, @@ -66,7 +64,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): netatmo = get_component('netatmo') home = config.get(CONF_HOME) timeout = config.get(CONF_TIMEOUT) - offset = config.get(CONF_OFFSET) + if timeout is None: + timeout = DEFAULT_TIMEOUT module_name = None @@ -94,7 +93,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for variable in welcome_sensors: add_devices([NetatmoBinarySensor( data, camera_name, module_name, home, timeout, - offset, camera_type, variable)], True) + camera_type, variable)], True) if camera_type == 'NOC': if CONF_CAMERAS in config: if config[CONF_CAMERAS] != [] and \ @@ -102,14 +101,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): continue for variable in presence_sensors: add_devices([NetatmoBinarySensor( - data, camera_name, module_name, home, timeout, offset, + data, camera_name, module_name, home, timeout, camera_type, variable)], True) for module_name in data.get_module_names(camera_name): for variable in tag_sensors: camera_type = None add_devices([NetatmoBinarySensor( - data, camera_name, module_name, home, timeout, offset, + data, camera_name, module_name, home, timeout, camera_type, variable)], True) @@ -117,14 +116,13 @@ class NetatmoBinarySensor(BinarySensorDevice): """Represent a single binary sensor in a Netatmo Camera device.""" def __init__(self, data, camera_name, module_name, home, - timeout, offset, camera_type, sensor): + timeout, camera_type, sensor): """Set up for access to the Netatmo camera events.""" self._data = data self._camera_name = camera_name self._module_name = module_name self._home = home self._timeout = timeout - self._offset = offset if home: self._name = '{} / {}'.format(home, camera_name) else: @@ -173,40 +171,39 @@ def update(self): if self._sensor_name == "Someone known": self._state =\ self._data.camera_data.someoneKnownSeen( - self._home, self._camera_name, self._timeout*60) + self._home, self._camera_name, self._timeout) elif self._sensor_name == "Someone unknown": self._state =\ self._data.camera_data.someoneUnknownSeen( - self._home, self._camera_name, self._timeout*60) + self._home, self._camera_name, self._timeout) elif self._sensor_name == "Motion": self._state =\ self._data.camera_data.motionDetected( - self._home, self._camera_name, self._timeout*60) + self._home, self._camera_name, self._timeout) elif self._cameratype == 'NOC': if self._sensor_name == "Outdoor motion": self._state =\ self._data.camera_data.outdoormotionDetected( - self._home, self._camera_name, self._offset) + self._home, self._camera_name, self._timeout) elif self._sensor_name == "Outdoor human": self._state =\ self._data.camera_data.humanDetected( - self._home, self._camera_name, self._offset) + self._home, self._camera_name, self._timeout) elif self._sensor_name == "Outdoor animal": self._state =\ self._data.camera_data.animalDetected( - self._home, self._camera_name, self._offset) + self._home, self._camera_name, self._timeout) elif self._sensor_name == "Outdoor vehicle": self._state =\ self._data.camera_data.carDetected( - self._home, self._camera_name, self._offset) + self._home, self._camera_name, self._timeout) if self._sensor_name == "Tag Vibration": self._state =\ self._data.camera_data.moduleMotionDetected( self._home, self._module_name, self._camera_name, - self._timeout*60) + self._timeout) elif self._sensor_name == "Tag Open": self._state =\ self._data.camera_data.moduleOpened( - self._home, self._module_name, self._camera_name) - else: - return None + self._home, self._module_name, self._camera_name, + self._timeout) diff --git a/homeassistant/components/binary_sensor/raincloud.py b/homeassistant/components/binary_sensor/raincloud.py new file mode 100644 index 00000000000000..f75f7644c4edb6 --- /dev/null +++ b/homeassistant/components/binary_sensor/raincloud.py @@ -0,0 +1,72 @@ +""" +Support for Melnor RainCloud sprinkler water timer. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.raincloud/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.raincloud import ( + BINARY_SENSORS, DATA_RAINCLOUD, ICON_MAP, RainCloudEntity) +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import CONF_MONITORED_CONDITIONS + +DEPENDENCIES = ['raincloud'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSORS)): + vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up a sensor for a raincloud device.""" + raincloud = hass.data[DATA_RAINCLOUD].data + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + if sensor_type == 'status': + sensors.append( + RainCloudBinarySensor(raincloud.controller, sensor_type)) + sensors.append( + RainCloudBinarySensor(raincloud.controller.faucet, + sensor_type)) + + else: + # create an sensor for each zone managed by faucet + for zone in raincloud.controller.faucet.zones: + sensors.append(RainCloudBinarySensor(zone, sensor_type)) + + add_devices(sensors, True) + return True + + +class RainCloudBinarySensor(RainCloudEntity, BinarySensorDevice): + """A sensor implementation for raincloud device.""" + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + def update(self): + """Get the latest data and updates the state.""" + _LOGGER.debug("Updating RainCloud sensor: %s", self._name) + self._state = getattr(self.data, self._sensor_type) + if self._sensor_type == 'status': + self._state = self._state == 'Online' + + @property + def icon(self): + """Return the icon of this device.""" + if self._sensor_type == 'is_watering': + return 'mdi:water' if self.is_on else 'mdi:water-off' + elif self._sensor_type == 'status': + return 'mdi:pipe' if self.is_on else 'mdi:pipe-disconnected' + return ICON_MAP.get(self._sensor_type) diff --git a/homeassistant/components/binary_sensor/satel_integra.py b/homeassistant/components/binary_sensor/satel_integra.py new file mode 100644 index 00000000000000..f373809f7c062f --- /dev/null +++ b/homeassistant/components/binary_sensor/satel_integra.py @@ -0,0 +1,90 @@ +""" +Support for Satel Integra zone states- represented as binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.satel_integra/ +""" +import asyncio +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.satel_integra import (CONF_ZONES, + CONF_ZONE_NAME, + CONF_ZONE_TYPE, + SIGNAL_ZONES_UPDATED) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +DEPENDENCIES = ['satel_integra'] + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Satel Integra binary sensor devices.""" + if not discovery_info: + return + + configured_zones = discovery_info[CONF_ZONES] + + devices = [] + + for zone_num, device_config_data in configured_zones.items(): + zone_type = device_config_data[CONF_ZONE_TYPE] + zone_name = device_config_data[CONF_ZONE_NAME] + device = SatelIntegraBinarySensor(zone_num, zone_name, zone_type) + devices.append(device) + + async_add_devices(devices) + + +class SatelIntegraBinarySensor(BinarySensorDevice): + """Representation of an Satel Integra binary sensor.""" + + def __init__(self, zone_number, zone_name, zone_type): + """Initialize the binary_sensor.""" + self._zone_number = zone_number + self._name = zone_name + self._zone_type = zone_type + self._state = 0 + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_ZONES_UPDATED, self._zones_updated) + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def icon(self): + """Icon for device by its type.""" + if self._zone_type == 'smoke': + return "mdi:fire" + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state == 1 + + @property + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + return self._zone_type + + @callback + def _zones_updated(self, zones): + """Update the zone's state, if needed.""" + if self._zone_number in zones \ + and self._state != zones[self._zone_number]: + self._state = zones[self._zone_number] + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/skybell.py b/homeassistant/components/binary_sensor/skybell.py new file mode 100644 index 00000000000000..734f8e03375e5f --- /dev/null +++ b/homeassistant/components/binary_sensor/skybell.py @@ -0,0 +1,97 @@ +""" +Binary sensor support for the Skybell HD Doorbell. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.skybell/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.components.skybell import ( + DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice) +from homeassistant.const import ( + CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS) +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['skybell'] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=5) + +# Sensor types: Name, device_class, event +SENSOR_TYPES = { + 'button': ['Button', 'occupancy', 'device:sensor:button'], + 'motion': ['Motion', 'motion', 'device:sensor:motion'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE): + cv.string, + vol.Required(CONF_MONITORED_CONDITIONS, default=[]): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the platform for a Skybell device.""" + skybell = hass.data.get(SKYBELL_DOMAIN) + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + for device in skybell.get_devices(): + sensors.append(SkybellBinarySensor(device, sensor_type)) + + add_devices(sensors, True) + + +class SkybellBinarySensor(SkybellDevice, BinarySensorDevice): + """A binary sensor implementation for Skybell devices.""" + + def __init__(self, device, sensor_type): + """Initialize a binary sensor for a Skybell device.""" + super().__init__(device) + self._sensor_type = sensor_type + self._name = "{0} {1}".format(self._device.name, + SENSOR_TYPES[self._sensor_type][0]) + self._device_class = SENSOR_TYPES[self._sensor_type][1] + self._event = {} + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the class of the binary sensor.""" + return self._device_class + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = super().device_state_attributes + + attrs['event_date'] = self._event.get('createdAt') + + return attrs + + def update(self): + """Get the latest data and updates the state.""" + super().update() + + event = self._device.latest(SENSOR_TYPES[self._sensor_type][2]) + + self._state = bool(event and event.get('id') != self._event.get('id')) + + self._event = event diff --git a/homeassistant/components/binary_sensor/spc.py b/homeassistant/components/binary_sensor/spc.py index 8023e1cf4b3364..af3669c2b15a5e 100644 --- a/homeassistant/components/binary_sensor/spc.py +++ b/homeassistant/components/binary_sensor/spc.py @@ -41,14 +41,14 @@ def _create_sensor(hass, zone): @asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Initialize the platform.""" if (discovery_info is None or discovery_info[ATTR_DISCOVER_DEVICES] is None): return - async_add_entities( + async_add_devices( _create_sensor(hass, zone) for zone in discovery_info[ATTR_DISCOVER_DEVICES] if _get_device_class(zone['type'])) diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index 84afd01303fda2..16167a93b82080 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -15,13 +15,12 @@ DEVICE_CLASSES_SCHEMA) from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, CONF_VALUE_TEMPLATE, - CONF_SENSORS, CONF_DEVICE_CLASS, EVENT_HOMEASSISTANT_START, STATE_ON) + CONF_SENSORS, CONF_DEVICE_CLASS, EVENT_HOMEASSISTANT_START) from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.event import ( async_track_state_change, async_track_same_state) -from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) @@ -94,10 +93,6 @@ def __init__(self, hass, device, friendly_name, device_class, @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - state = yield from async_get_last_state(self.hass, self.entity_id) - if state: - self._state = state.state == STATE_ON - @callback def template_bsensor_state_listener(entity, old_state, new_state): """Handle the target device state changes.""" @@ -135,7 +130,7 @@ def should_poll(self): return False @callback - def _async_render(self, *args): + def _async_render(self): """Get the state of template.""" try: return self._template.async_render().lower() == 'true' @@ -171,5 +166,5 @@ def set_state(): period = self._delay_on if state else self._delay_off async_track_same_state( - self.hass, state, period, set_state, entity_ids=self._entities, - async_check_func=self._async_render) + self.hass, period, set_state, entity_ids=self._entities, + async_check_same_func=lambda *args: self._async_render() == state) diff --git a/homeassistant/components/binary_sensor/tesla.py b/homeassistant/components/binary_sensor/tesla.py index af7e394b50e112..a7cda90b3f65e7 100644 --- a/homeassistant/components/binary_sensor/tesla.py +++ b/homeassistant/components/binary_sensor/tesla.py @@ -30,7 +30,6 @@ class TeslaBinarySensor(TeslaDevice, BinarySensorDevice): def __init__(self, tesla_device, controller, sensor_type): """Initialisation of binary sensor.""" super().__init__(tesla_device, controller) - self._name = self.tesla_device.name self._state = False self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) self._sensor_type = sensor_type diff --git a/homeassistant/components/binary_sensor/threshold.py b/homeassistant/components/binary_sensor/threshold.py index 866e16ecbe22da..5ca037767f29bb 100644 --- a/homeassistant/components/binary_sensor/threshold.py +++ b/homeassistant/components/binary_sensor/threshold.py @@ -20,15 +20,18 @@ _LOGGER = logging.getLogger(__name__) +ATTR_HYSTERESIS = 'hysteresis' ATTR_SENSOR_VALUE = 'sensor_value' ATTR_THRESHOLD = 'threshold' ATTR_TYPE = 'type' +CONF_HYSTERESIS = 'hysteresis' CONF_LOWER = 'lower' CONF_THRESHOLD = 'threshold' CONF_UPPER = 'upper' DEFAULT_NAME = 'Threshold' +DEFAULT_HYSTERESIS = 0.0 SENSOR_TYPES = [CONF_LOWER, CONF_UPPER] @@ -36,6 +39,8 @@ vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_THRESHOLD): vol.Coerce(float), vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), + vol.Optional( + CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): vol.Coerce(float), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, }) @@ -47,28 +52,32 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): entity_id = config.get(CONF_ENTITY_ID) name = config.get(CONF_NAME) threshold = config.get(CONF_THRESHOLD) + hysteresis = config.get(CONF_HYSTERESIS) limit_type = config.get(CONF_TYPE) device_class = config.get(CONF_DEVICE_CLASS) - async_add_devices( - [ThresholdSensor(hass, entity_id, name, threshold, limit_type, - device_class)], True) + async_add_devices([ThresholdSensor( + hass, entity_id, name, threshold, + hysteresis, limit_type, device_class) + ], True) + return True class ThresholdSensor(BinarySensorDevice): """Representation of a Threshold sensor.""" - def __init__(self, hass, entity_id, name, threshold, limit_type, - device_class): + def __init__(self, hass, entity_id, name, threshold, + hysteresis, limit_type, device_class): """Initialize the Threshold sensor.""" self._hass = hass self._entity_id = entity_id self.is_upper = limit_type == 'upper' self._name = name self._threshold = threshold + self._hysteresis = hysteresis self._device_class = device_class - self._deviation = False + self._state = False self.sensor_value = 0 @callback @@ -97,7 +106,7 @@ def name(self): @property def is_on(self): """Return true if sensor is on.""" - return self._deviation + return self._state @property def should_poll(self): @@ -116,13 +125,16 @@ def device_state_attributes(self): ATTR_ENTITY_ID: self._entity_id, ATTR_SENSOR_VALUE: self.sensor_value, ATTR_THRESHOLD: self._threshold, + ATTR_HYSTERESIS: self._hysteresis, ATTR_TYPE: CONF_UPPER if self.is_upper else CONF_LOWER, } @asyncio.coroutine def async_update(self): """Get the latest data and updates the states.""" - if self.is_upper: - self._deviation = bool(self.sensor_value > self._threshold) - else: - self._deviation = bool(self.sensor_value < self._threshold) + if self._hysteresis == 0 and self.sensor_value == self._threshold: + self._state = False + elif self.sensor_value > (self._threshold + self._hysteresis): + self._state = self.is_upper + elif self.sensor_value < (self._threshold - self._hysteresis): + self._state = not self.is_upper diff --git a/homeassistant/components/binary_sensor/wink.py b/homeassistant/components/binary_sensor/wink.py index b4910687da7d31..e0bf23ecee24f6 100644 --- a/homeassistant/components/binary_sensor/wink.py +++ b/homeassistant/components/binary_sensor/wink.py @@ -9,7 +9,6 @@ from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.wink import WinkDevice, DOMAIN -from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -87,7 +86,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.info("Device isn't a sensor, skipping") -class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice, Entity): +class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice): """Representation of a Wink binary sensor.""" def __init__(self, wink, hass): @@ -117,6 +116,11 @@ def device_class(self): """Return the class of this sensor, from DEVICE_CLASSES.""" return SENSOR_TYPES.get(self.capability) + @property + def device_state_attributes(self): + """Return the state attributes.""" + return super().device_state_attributes + class WinkSmokeDetector(WinkBinarySensorDevice): """Representation of a Wink Smoke detector.""" @@ -124,9 +128,9 @@ class WinkSmokeDetector(WinkBinarySensorDevice): @property def device_state_attributes(self): """Return the state attributes.""" - return { - 'test_activated': self.wink.test_activated() - } + _attributes = super().device_state_attributes + _attributes['test_activated'] = self.wink.test_activated() + return _attributes class WinkHub(WinkBinarySensorDevice): @@ -135,10 +139,11 @@ class WinkHub(WinkBinarySensorDevice): @property def device_state_attributes(self): """Return the state attributes.""" - return { - 'update needed': self.wink.update_needed(), - 'firmware version': self.wink.firmware_version() - } + _attributes = super().device_state_attributes + _attributes['update_needed'] = self.wink.update_needed() + _attributes['firmware_version'] = self.wink.firmware_version() + _attributes['pairing_mode'] = self.wink.pairing_mode() + return _attributes class WinkRemote(WinkBinarySensorDevice): @@ -147,12 +152,12 @@ class WinkRemote(WinkBinarySensorDevice): @property def device_state_attributes(self): """Return the state attributes.""" - return { - 'button_on_pressed': self.wink.button_on_pressed(), - 'button_off_pressed': self.wink.button_off_pressed(), - 'button_up_pressed': self.wink.button_up_pressed(), - 'button_down_pressed': self.wink.button_down_pressed() - } + _attributes = super().device_state_attributes + _attributes['button_on_pressed'] = self.wink.button_on_pressed() + _attributes['button_off_pressed'] = self.wink.button_off_pressed() + _attributes['button_up_pressed'] = self.wink.button_up_pressed() + _attributes['button_down_pressed'] = self.wink.button_down_pressed() + return _attributes @property def device_class(self): @@ -166,10 +171,10 @@ class WinkButton(WinkBinarySensorDevice): @property def device_state_attributes(self): """Return the state attributes.""" - return { - 'pressed': self.wink.pressed(), - 'long_pressed': self.wink.long_pressed() - } + _attributes = super().device_state_attributes + _attributes['pressed'] = self.wink.pressed() + _attributes['long_pressed'] = self.wink.long_pressed() + return _attributes class WinkGang(WinkBinarySensorDevice): diff --git a/homeassistant/components/binary_sensor/xiaomi.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py similarity index 95% rename from homeassistant/components/binary_sensor/xiaomi.py rename to homeassistant/components/binary_sensor/xiaomi_aqara.py index c5f0a7b3dce244..a610269cedfff2 100644 --- a/homeassistant/components/binary_sensor/xiaomi.py +++ b/homeassistant/components/binary_sensor/xiaomi_aqara.py @@ -1,8 +1,9 @@ -"""Support for Xiaomi binary sensors.""" +"""Support for Xiaomi aqara binary sensors.""" import logging from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, + XiaomiDevice) _LOGGER = logging.getLogger(__name__) @@ -11,6 +12,7 @@ MOTION = 'motion' NO_MOTION = 'no_motion' +ATTR_LAST_ACTION = 'last_action' ATTR_NO_MOTION_SINCE = 'No motion since' DENSITY = 'density' @@ -326,10 +328,18 @@ class XiaomiCube(XiaomiBinarySensor): def __init__(self, device, hass, xiaomi_hub): """Initialize the Xiaomi Cube.""" self._hass = hass + self._last_action = None self._state = False XiaomiBinarySensor.__init__(self, device, 'Cube', xiaomi_hub, None, None) + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {ATTR_LAST_ACTION: self._last_action} + attrs.update(super().device_state_attributes) + return attrs + def parse_data(self, data): """Parse data sent by gateway.""" if 'status' in data: @@ -337,6 +347,7 @@ def parse_data(self, data): 'entity_id': self.entity_id, 'action_type': data['status'] }) + self._last_action = data['status'] if 'rotate' in data: self._hass.bus.fire('cube_action', { @@ -344,4 +355,6 @@ def parse_data(self, data): 'action_type': 'rotate', 'action_value': float(data['rotate'].replace(",", ".")) }) - return False + self._last_action = 'rotate' + + return True diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 4e088c8a640b0d..5198381b9767a3 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -12,6 +12,7 @@ from homeassistant.components.google import ( CONF_OFFSET, CONF_DEVICE_ID, CONF_NAME) from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.config_validation import time_period_str from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.helpers.entity_component import EntityComponent diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml new file mode 100644 index 00000000000000..952e2302091438 --- /dev/null +++ b/homeassistant/components/calendar/services.yaml @@ -0,0 +1,19 @@ +todoist: + new_task: + description: Create a new task and add it to a project. + fields: + content: + description: The name of the task. [Required] + example: Pick up the mail + project: + description: The name of the project this task should belong to. Defaults to Inbox. [Optional] + example: Errands + labels: + description: Any labels that you want to apply to this task, separated by a comma. [Optional] + example: Chores,Deliveries + priority: + description: The priority of this task, from 1 (normal) to 4 (urgent). [Optional] + example: 2 + due_date: + description: The day this task is due, in format YYYY-MM-DD. [Optional] + example: "2018-04-01" diff --git a/homeassistant/components/calendar/todoist.py b/homeassistant/components/calendar/todoist.py new file mode 100644 index 00000000000000..eb9f0a2677e239 --- /dev/null +++ b/homeassistant/components/calendar/todoist.py @@ -0,0 +1,544 @@ +""" +Support for Todoist task management (https://todoist.com). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/calendar.todoist/ +""" + + +from datetime import datetime +from datetime import timedelta +import logging +import os + +import voluptuous as vol + +from homeassistant.components.calendar import ( + CalendarEventDevice, PLATFORM_SCHEMA) +from homeassistant.components.google import ( + CONF_DEVICE_ID) +from homeassistant.config import load_yaml_config_file +from homeassistant.const import ( + CONF_ID, CONF_NAME, CONF_TOKEN) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.template import DATE_STR_FORMAT +from homeassistant.util import dt +from homeassistant.util import Throttle + +REQUIREMENTS = ['todoist-python==7.0.17'] + +_LOGGER = logging.getLogger(__name__) +DOMAIN = 'todoist' + +# Calendar Platform: Does this calendar event last all day? +ALL_DAY = 'all_day' +# Attribute: All tasks in this project +ALL_TASKS = 'all_tasks' +# Todoist API: "Completed" flag -- 1 if complete, else 0 +CHECKED = 'checked' +# Attribute: Is this task complete? +COMPLETED = 'completed' +# Todoist API: What is this task about? +# Service Call: What is this task about? +CONTENT = 'content' +# Calendar Platform: Get a calendar event's description +DESCRIPTION = 'description' +# Calendar Platform: Used in the '_get_date()' method +DATETIME = 'dateTime' +# Attribute: When is this task due? +# Service Call: When is this task due? +DUE_DATE = 'due_date' +# Todoist API: Look up a task's due date +DUE_DATE_UTC = 'due_date_utc' +# Attribute: Is this task due today? +DUE_TODAY = 'due_today' +# Calendar Platform: When a calendar event ends +END = 'end' +# Todoist API: Look up a Project/Label/Task ID +ID = 'id' +# Todoist API: Fetch all labels +# Service Call: What are the labels attached to this task? +LABELS = 'labels' +# Todoist API: "Name" value +NAME = 'name' +# Attribute: Is this task overdue? +OVERDUE = 'overdue' +# Attribute: What is this task's priority? +# Todoist API: Get a task's priority +# Service Call: What is this task's priority? +PRIORITY = 'priority' +# Todoist API: Look up the Project ID a Task belongs to +PROJECT_ID = 'project_id' +# Service Call: What Project do you want a Task added to? +PROJECT_NAME = 'project' +# Todoist API: Fetch all Projects +PROJECTS = 'projects' +# Calendar Platform: When does a calendar event start? +START = 'start' +# Calendar Platform: What is the next calendar event about? +SUMMARY = 'summary' +# Todoist API: Fetch all Tasks +TASKS = 'items' + +SERVICE_NEW_TASK = 'new_task' +NEW_TASK_SERVICE_SCHEMA = vol.Schema({ + vol.Required(CONTENT): cv.string, + vol.Optional(PROJECT_NAME, default='inbox'): vol.All(cv.string, vol.Lower), + vol.Optional(LABELS): cv.ensure_list_csv, + vol.Optional(PRIORITY): vol.All(vol.Coerce(int), + vol.Range(min=1, max=4)), + vol.Optional(DUE_DATE): cv.string +}) + +CONF_EXTRA_PROJECTS = 'custom_projects' +CONF_PROJECT_DUE_DATE = 'due_date_days' +CONF_PROJECT_WHITELIST = 'include_projects' +CONF_PROJECT_LABEL_WHITELIST = 'labels' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_EXTRA_PROJECTS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_PROJECT_DUE_DATE): vol.Coerce(int), + vol.Optional(CONF_PROJECT_WHITELIST, default=[]): + vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]), + vol.Optional(CONF_PROJECT_LABEL_WHITELIST, default=[]): + vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]) + }) + ])) +}) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Todoist platform.""" + # Check token: + token = config.get(CONF_TOKEN) + + # Look up IDs based on (lowercase) names. + project_id_lookup = {} + label_id_lookup = {} + + from todoist.api import TodoistAPI + api = TodoistAPI(token) + api.sync() + + # Setup devices: + # Grab all projects. + projects = api.state[PROJECTS] + + # Grab all labels + labels = api.state[LABELS] + + # Add all Todoist-defined projects. + project_devices = [] + for project in projects: + # Project is an object, not a dict! + # Because of that, we convert what we need to a dict. + project_data = { + CONF_NAME: project[NAME], + CONF_ID: project[ID] + } + project_devices.append( + TodoistProjectDevice(hass, project_data, labels, api) + ) + # Cache the names so we can easily look up name->ID. + project_id_lookup[project[NAME].lower()] = project[ID] + + # Cache all label names + for label in labels: + label_id_lookup[label[NAME].lower()] = label[ID] + + # Check config for more projects. + extra_projects = config.get(CONF_EXTRA_PROJECTS) + for project in extra_projects: + # Special filter: By date + project_due_date = project.get(CONF_PROJECT_DUE_DATE) + + # Special filter: By label + project_label_filter = project.get(CONF_PROJECT_LABEL_WHITELIST) + + # Special filter: By name + # Names must be converted into IDs. + project_name_filter = project.get(CONF_PROJECT_WHITELIST) + project_id_filter = [ + project_id_lookup[project_name.lower()] + for project_name in project_name_filter] + + # Create the custom project and add it to the devices array. + project_devices.append( + TodoistProjectDevice( + hass, project, labels, api, project_due_date, + project_label_filter, project_id_filter + ) + ) + + add_devices(project_devices) + + # Services: + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + def handle_new_task(call): + """Called when a user creates a new Todoist Task from HASS.""" + project_name = call.data[PROJECT_NAME] + project_id = project_id_lookup[project_name] + + # Create the task + item = api.items.add(call.data[CONTENT], project_id) + + if LABELS in call.data: + task_labels = call.data[LABELS] + label_ids = [ + label_id_lookup[label.lower()] + for label in task_labels] + item.update(labels=label_ids) + + if PRIORITY in call.data: + item.update(priority=call.data[PRIORITY]) + + if DUE_DATE in call.data: + due_date = dt.parse_datetime(call.data[DUE_DATE]) + if due_date is None: + due = dt.parse_date(call.data[DUE_DATE]) + due_date = datetime(due.year, due.month, due.day) + # Format it in the manner Todoist expects + due_date = dt.as_utc(due_date) + date_format = '%Y-%m-%dT%H:%M' + due_date = datetime.strftime(due_date, date_format) + item.update(due_date_utc=due_date) + # Commit changes + api.commit() + _LOGGER.debug("Created Todoist task: %s", call.data[CONTENT]) + + hass.services.register(DOMAIN, SERVICE_NEW_TASK, handle_new_task, + descriptions[DOMAIN][SERVICE_NEW_TASK], + schema=NEW_TASK_SERVICE_SCHEMA) + + +class TodoistProjectDevice(CalendarEventDevice): + """A device for getting the next Task from a Todoist Project.""" + + def __init__(self, hass, data, labels, token, + latest_task_due_date=None, whitelisted_labels=None, + whitelisted_projects=None): + """Create the Todoist Calendar Event Device.""" + self.data = TodoistProjectData( + data, labels, token, latest_task_due_date, + whitelisted_labels, whitelisted_projects + ) + + # Set up the calendar side of things + calendar_format = { + CONF_NAME: data[CONF_NAME], + # Set Entity ID to use the name so we can identify calendars + CONF_DEVICE_ID: data[CONF_NAME] + } + + super().__init__(hass, calendar_format) + + def update(self): + """Update all Todoist Calendars.""" + # Set basic calendar data + super().update() + + # Set Todoist-specific data that can't easily be grabbed + self._cal_data[ALL_TASKS] = [ + task[SUMMARY] for task in self.data.all_project_tasks] + + def cleanup(self): + """Clean up all calendar data.""" + super().cleanup() + self._cal_data[ALL_TASKS] = [] + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.data.event is None: + # No tasks, we don't REALLY need to show anything. + return {} + + attributes = super().device_state_attributes + + # Add additional attributes. + attributes[DUE_TODAY] = self.data.event[DUE_TODAY] + attributes[OVERDUE] = self.data.event[OVERDUE] + attributes[ALL_TASKS] = self._cal_data[ALL_TASKS] + attributes[PRIORITY] = self.data.event[PRIORITY] + attributes[LABELS] = self.data.event[LABELS] + + return attributes + + +class TodoistProjectData(object): + """ + Class used by the Task Device service object to hold all Todoist Tasks. + + This is analogous to the GoogleCalendarData found in the Google Calendar + component. + + Takes an object with a 'name' field and optionally an 'id' field (either + user-defined or from the Todoist API), a Todoist API token, and an optional + integer specifying the latest number of days from now a task can be due (7 + means everything due in the next week, 0 means today, etc.). + + This object has an exposed 'event' property (used by the Calendar platform + to determine the next calendar event) and an exposed 'update' method (used + by the Calendar platform to poll for new calendar events). + + The 'event' is a representation of a Todoist Task, with defined parameters + of 'due_today' (is the task due today?), 'all_day' (does the task have a + due date?), 'task_labels' (all labels assigned to the task), 'message' + (the content of the task, e.g. 'Fetch Mail'), 'description' (a URL pointing + to the task on the Todoist website), 'end_time' (what time the event is + due), 'start_time' (what time this event was last updated), 'overdue' (is + the task past its due date?), 'priority' (1-4, how important the task is, + with 4 being the most important), and 'all_tasks' (all tasks in this + project, sorted by how important they are). + + 'offset_reached', 'location', and 'friendly_name' are defined by the + platform itself, but are not used by this component at all. + + The 'update' method polls the Todoist API for new projects/tasks, as well + as any updates to current projects/tasks. This is throttled to every + MIN_TIME_BETWEEN_UPDATES minutes. + """ + + def __init__(self, project_data, labels, api, + latest_task_due_date=None, whitelisted_labels=None, + whitelisted_projects=None): + """Initialize a Todoist Project.""" + self.event = None + + self._api = api + self._name = project_data.get(CONF_NAME) + # If no ID is defined, fetch all tasks. + self._id = project_data.get(CONF_ID) + + # All labels the user has defined, for easy lookup. + self._labels = labels + # Not tracked: order, indent, comment_count. + + self.all_project_tasks = [] + + # The latest date a task can be due (for making lists of everything + # due today, or everything due in the next week, for example). + if latest_task_due_date is not None: + self._latest_due_date = dt.utcnow() + timedelta( + days=latest_task_due_date) + else: + self._latest_due_date = None + + # Only tasks with one of these labels will be included. + if whitelisted_labels is not None: + self._label_whitelist = whitelisted_labels + else: + self._label_whitelist = [] + + # This project includes only projects with these names. + if whitelisted_projects is not None: + self._project_id_whitelist = whitelisted_projects + else: + self._project_id_whitelist = [] + + def create_todoist_task(self, data): + """ + Create a dictionary based on a Task passed from the Todoist API. + + Will return 'None' if the task is to be filtered out. + """ + task = {} + # Fields are required to be in all returned task objects. + task[SUMMARY] = data[CONTENT] + task[COMPLETED] = data[CHECKED] == 1 + task[PRIORITY] = data[PRIORITY] + task[DESCRIPTION] = 'https://todoist.com/showTask?id={}'.format( + data[ID]) + + # All task Labels (optional parameter). + task[LABELS] = [ + label[NAME].lower() for label in self._labels + if label[ID] in data[LABELS]] + + if self._label_whitelist and ( + not any(label in task[LABELS] + for label in self._label_whitelist)): + # We're not on the whitelist, return invalid task. + return None + + # Due dates (optional parameter). + # The due date is the END date -- the task cannot be completed + # past this time. + # That means that the START date is the earliest time one can + # complete the task. + # Generally speaking, that means right now. + task[START] = dt.utcnow() + if data[DUE_DATE_UTC] is not None: + due_date = data[DUE_DATE_UTC] + + # Due dates are represented in RFC3339 format, in UTC. + # Home Assistant exclusively uses UTC, so it'll + # handle the conversion. + time_format = '%a %d %b %Y %H:%M:%S %z' + # HASS' built-in parse time function doesn't like + # Todoist's time format; strptime has to be used. + task[END] = datetime.strptime(due_date, time_format) + + if self._latest_due_date is not None and ( + task[END] > self._latest_due_date): + # This task is out of range of our due date; + # it shouldn't be counted. + return None + + task[DUE_TODAY] = task[END].date() == datetime.today().date() + + # Special case: Task is overdue. + if task[END] <= task[START]: + task[OVERDUE] = True + # Set end time to the current time plus 1 hour. + # We're pretty much guaranteed to update within that 1 hour, + # so it should be fine. + task[END] = task[START] + timedelta(hours=1) + else: + task[OVERDUE] = False + else: + # If we ask for everything due before a certain date, don't count + # things which have no due dates. + if self._latest_due_date is not None: + return None + + # Define values for tasks without due dates + task[END] = None + task[ALL_DAY] = True + task[DUE_TODAY] = False + task[OVERDUE] = False + + # Not tracked: id, comments, project_id order, indent, recurring. + return task + + @staticmethod + def select_best_task(project_tasks): + """ + Search through a list of events for the "best" event to select. + + The "best" event is determined by the following criteria: + * A proposed event must not be completed + * A proposed event must have a end date (otherwise we go with + the event at index 0, selected above) + * A proposed event must be on the same day or earlier as our + current event + * If a proposed event is an earlier day than what we have so + far, select it + * If a proposed event is on the same day as our current event + and the proposed event has a higher priority than our current + event, select it + * If a proposed event is on the same day as our current event, + has the same priority as our current event, but is due earlier + in the day, select it + """ + # Start at the end of the list, so if tasks don't have a due date + # the newest ones are the most important. + + event = project_tasks[-1] + + for proposed_event in project_tasks: + if event == proposed_event: + continue + if proposed_event[COMPLETED]: + # Event is complete! + continue + if proposed_event[END] is None: + # No end time: + if event[END] is None and ( + proposed_event[PRIORITY] < event[PRIORITY]): + # They also have no end time, + # but we have a higher priority. + event = proposed_event + continue + else: + continue + elif event[END] is None: + # We have an end time, they do not. + event = proposed_event + continue + if proposed_event[END].date() > event[END].date(): + # Event is too late. + continue + elif proposed_event[END].date() < event[END].date(): + # Event is earlier than current, select it. + event = proposed_event + continue + else: + if proposed_event[PRIORITY] > event[PRIORITY]: + # Proposed event has a higher priority. + event = proposed_event + continue + elif proposed_event[PRIORITY] == event[PRIORITY] and ( + proposed_event[END] < event[END]): + event = proposed_event + continue + return event + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data.""" + if self._id is None: + project_task_data = [ + task for task in self._api.state[TASKS] + if not self._project_id_whitelist or + task[PROJECT_ID] in self._project_id_whitelist] + else: + project_task_data = self._api.projects.get_data(self._id)[TASKS] + + # If we have no data, we can just return right away. + if not project_task_data: + self.event = None + return True + + # Keep an updated list of all tasks in this project. + project_tasks = [] + + for task in project_task_data: + todoist_task = self.create_todoist_task(task) + if todoist_task is not None: + # A None task means it is invalid for this project + project_tasks.append(todoist_task) + + if not project_tasks: + # We had no valid tasks + return True + + # Organize the best tasks (so users can see all the tasks + # they have, organized) + while len(project_tasks) > 0: + best_task = self.select_best_task(project_tasks) + _LOGGER.debug("Found Todoist Task: %s", best_task[SUMMARY]) + project_tasks.remove(best_task) + self.all_project_tasks.append(best_task) + + self.event = self.all_project_tasks[0] + + # Convert datetime to a string again + if self.event is not None: + if self.event[START] is not None: + self.event[START] = { + DATETIME: self.event[START].strftime(DATE_STR_FORMAT) + } + if self.event[END] is not None: + self.event[END] = { + DATETIME: self.event[END].strftime(DATE_STR_FORMAT) + } + else: + # HASS gets cranky if a calendar event never ends + # Let's set our "due date" to tomorrow + self.event[END] = { + DATETIME: ( + datetime.utcnow() + + timedelta(days=1) + ).strftime(DATE_STR_FORMAT) + } + _LOGGER.debug("Updated %s", self._name) + return True diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index a7d778d99aa790..c509d582e1194b 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -126,23 +126,16 @@ def async_handle_camera_service(service): """Handle calls to the camera services.""" target_cameras = component.async_extract_from_service(service) + update_tasks = [] for camera in target_cameras: if service.service == SERVICE_EN_MOTION: yield from camera.async_enable_motion_detection() elif service.service == SERVICE_DISEN_MOTION: yield from camera.async_disable_motion_detection() - update_tasks = [] - for camera in target_cameras: if not camera.should_poll: continue - - update_coro = hass.async_add_job( - camera.async_update_ha_state(True)) - if hasattr(camera, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(camera.async_update_ha_state(True)) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) diff --git a/homeassistant/components/camera/abode.py b/homeassistant/components/camera/abode.py new file mode 100644 index 00000000000000..3c0c0a54e0e59d --- /dev/null +++ b/homeassistant/components/camera/abode.py @@ -0,0 +1,101 @@ +""" +This component provides HA camera support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.abode/ +""" +import asyncio +import logging + +from datetime import timedelta +import requests + +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN +from homeassistant.components.camera import Camera +from homeassistant.util import Throttle + + +DEPENDENCIES = ['abode'] + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discoveryy_info=None): + """Set up Abode camera devices.""" + import abodepy.helpers.constants as CONST + import abodepy.helpers.timeline as TIMELINE + + data = hass.data[ABODE_DOMAIN] + + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_CAMERA): + if data.is_excluded(device): + continue + + devices.append(AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE)) + + data.devices.extend(devices) + + add_devices(devices) + + +class AbodeCamera(AbodeDevice, Camera): + """Representation of an Abode camera.""" + + def __init__(self, data, device, event): + """Initialize the Abode device.""" + AbodeDevice.__init__(self, data, device) + Camera.__init__(self) + self._event = event + self._response = None + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe Abode events.""" + yield from super().async_added_to_hass() + + self.hass.async_add_job( + self._data.abode.events.add_timeline_callback, + self._event, self._capture_callback + ) + + def capture(self): + """Request a new image capture.""" + return self._device.capture() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def refresh_image(self): + """Find a new image on the timeline.""" + if self._device.refresh_image(): + self.get_image() + + def get_image(self): + """Attempt to download the most recent capture.""" + if self._device.image_url: + try: + self._response = requests.get( + self._device.image_url, stream=True) + + self._response.raise_for_status() + except requests.HTTPError as err: + _LOGGER.warning("Failed to get camera image: %s", err) + self._response = None + else: + self._response = None + + def camera_image(self): + """Get a camera image.""" + self.refresh_image() + + if self._response: + return self._response.content + + return None + + def _capture_callback(self, capture): + """Update the image with the device then refresh device.""" + self._device.update_image_location(capture) + self.get_image() + self.schedule_update_ha_state() diff --git a/homeassistant/components/camera/amcrest.py b/homeassistant/components/camera/amcrest.py index 51b8ff13906f34..aba1bb08c93696 100644 --- a/homeassistant/components/camera/amcrest.py +++ b/homeassistant/components/camera/amcrest.py @@ -62,7 +62,7 @@ def __init__(self, hass, name, camera, authentication, self._token = self._auth = authentication def camera_image(self): - """Return a still image reponse from the camera.""" + """Return a still image response from the camera.""" # Send the request to snap a picture and return raw jpg data response = self._camera.snapshot(channel=self._resolution) return response.data diff --git a/homeassistant/components/camera/arlo.py b/homeassistant/components/camera/arlo.py index 80833e34b207e9..be58b61fb8c869 100644 --- a/homeassistant/components/camera/arlo.py +++ b/homeassistant/components/camera/arlo.py @@ -1,30 +1,50 @@ """ -This component provides basic support for Netgear Arlo IP cameras. +Support for Netgear Arlo IP cameras. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/camera.arlo/ """ import asyncio import logging +from datetime import timedelta import voluptuous as vol -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +import homeassistant.helpers.config_validation as cv from homeassistant.components.arlo import DEFAULT_BRAND, DATA_ARLO from homeassistant.components.camera import Camera, PLATFORM_SCHEMA from homeassistant.components.ffmpeg import DATA_FFMPEG - -DEPENDENCIES = ['arlo', 'ffmpeg'] +from homeassistant.const import ATTR_BATTERY_LEVEL +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream _LOGGER = logging.getLogger(__name__) -CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' +SCAN_INTERVAL = timedelta(minutes=10) + ARLO_MODE_ARMED = 'armed' ARLO_MODE_DISARMED = 'disarmed' +ATTR_BRIGHTNESS = 'brightness' +ATTR_FLIPPED = 'flipped' +ATTR_MIRRORED = 'mirrored' +ATTR_MOTION = 'motion_detection_sensitivity' +ATTR_POWERSAVE = 'power_save_mode' +ATTR_SIGNAL_STRENGTH = 'signal_strength' +ATTR_UNSEEN_VIDEOS = 'unseen_videos' + +CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' + +DEPENDENCIES = ['arlo', 'ffmpeg'] + +POWERSAVE_MODE_MAPPING = { + 1: 'best_battery_life', + 2: 'optimized', + 3: 'best_video' +} + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, + vol.Optional(CONF_FFMPEG_ARGUMENTS): + cv.string, }) @@ -53,6 +73,7 @@ def __init__(self, hass, camera, device_info): self._motion_status = False self._ffmpeg = hass.data[DATA_FFMPEG] self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) + self.attrs = {} def camera_image(self): """Return a still image response from the camera.""" @@ -80,14 +101,28 @@ def name(self): """Return the name of this camera.""" return self._name + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_BATTERY_LEVEL: self.attrs.get(ATTR_BATTERY_LEVEL), + ATTR_BRIGHTNESS: self.attrs.get(ATTR_BRIGHTNESS), + ATTR_FLIPPED: self.attrs.get(ATTR_FLIPPED), + ATTR_MIRRORED: self.attrs.get(ATTR_MIRRORED), + ATTR_MOTION: self.attrs.get(ATTR_MOTION), + ATTR_POWERSAVE: self.attrs.get(ATTR_POWERSAVE), + ATTR_SIGNAL_STRENGTH: self.attrs.get(ATTR_SIGNAL_STRENGTH), + ATTR_UNSEEN_VIDEOS: self.attrs.get(ATTR_UNSEEN_VIDEOS), + } + @property def model(self): - """Camera model.""" + """Return the camera model.""" return self._camera.model_id @property def brand(self): - """Camera brand.""" + """Return the camera brand.""" return DEFAULT_BRAND @property @@ -97,7 +132,7 @@ def should_poll(self): @property def motion_detection_enabled(self): - """Camera Motion Detection Status.""" + """Return the camera motion detection status.""" return self._motion_status def set_base_station_mode(self, mode): @@ -105,7 +140,7 @@ def set_base_station_mode(self, mode): # Get the list of base stations identified by library base_stations = self.hass.data[DATA_ARLO].base_stations - # Some Arlo cameras does not have basestation + # Some Arlo cameras does not have base station # So check if there is base station detected first # if yes, then choose the primary base station # Set the mode on the chosen base station @@ -122,3 +157,16 @@ def disable_motion_detection(self): """Disable the motion detection in base station (Disarm).""" self._motion_status = False self.set_base_station_mode(ARLO_MODE_DISARMED) + + def update(self): + """Add an attribute-update task to the executor pool.""" + self.attrs[ATTR_BATTERY_LEVEL] = self._camera.get_battery_level + self.attrs[ATTR_BRIGHTNESS] = self._camera.get_battery_level + self.attrs[ATTR_FLIPPED] = self._camera.get_flip_state, + self.attrs[ATTR_MIRRORED] = self._camera.get_mirror_state, + self.attrs[ + ATTR_MOTION] = self._camera.get_motion_detection_sensitivity, + self.attrs[ATTR_POWERSAVE] = POWERSAVE_MODE_MAPPING[ + self._camera.get_powersave_mode], + self.attrs[ATTR_SIGNAL_STRENGTH] = self._camera.get_signal_strength, + self.attrs[ATTR_UNSEEN_VIDEOS] = self._camera.unseen_videos diff --git a/homeassistant/components/camera/axis.py b/homeassistant/components/camera/axis.py index b0295b9ee34e77..ee8ccce1a9cb02 100644 --- a/homeassistant/components/camera/axis.py +++ b/homeassistant/components/camera/axis.py @@ -7,7 +7,7 @@ import logging from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, + CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION) from homeassistant.components.camera.mjpeg import ( CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera) @@ -19,38 +19,44 @@ DEPENDENCIES = [DOMAIN] -def _get_image_url(host, mode): +def _get_image_url(host, port, mode): if mode == 'mjpeg': - return 'http://{}/axis-cgi/mjpg/video.cgi'.format(host) + return 'http://{}:{}/axis-cgi/mjpg/video.cgi'.format(host, port) elif mode == 'single': - return 'http://{}/axis-cgi/jpg/image.cgi'.format(host) + return 'http://{}:{}/axis-cgi/jpg/image.cgi'.format(host, port) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup Axis camera.""" - config = { + camera_config = { CONF_NAME: discovery_info[CONF_NAME], CONF_USERNAME: discovery_info[CONF_USERNAME], CONF_PASSWORD: discovery_info[CONF_PASSWORD], - CONF_MJPEG_URL: _get_image_url(discovery_info[CONF_HOST], 'mjpeg'), + CONF_MJPEG_URL: _get_image_url(discovery_info[CONF_HOST], + str(discovery_info[CONF_PORT]), + 'mjpeg'), CONF_STILL_IMAGE_URL: _get_image_url(discovery_info[CONF_HOST], + str(discovery_info[CONF_PORT]), 'single'), CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION, } - add_devices([AxisCamera(hass, config)]) + add_devices([AxisCamera(hass, + camera_config, + str(discovery_info[CONF_PORT]))]) class AxisCamera(MjpegCamera): """AxisCamera class.""" - def __init__(self, hass, config): + def __init__(self, hass, config, port): """Initialize Axis Communications camera component.""" super().__init__(hass, config) + self.port = port async_dispatcher_connect(hass, DOMAIN + '_' + config[CONF_NAME] + '_new_ip', self._new_ip) def _new_ip(self, host): """Set new IP for video stream.""" - self._mjpeg_url = _get_image_url(host, 'mjpeg') - self._still_image_url = _get_image_url(host, 'mjpeg') + self._mjpeg_url = _get_image_url(host, self.port, 'mjpeg') + self._still_image_url = _get_image_url(host, self.port, 'single') diff --git a/homeassistant/components/camera/blink.py b/homeassistant/components/camera/blink.py index bca4fafec4faff..4b708817cfd9f3 100644 --- a/homeassistant/components/camera/blink.py +++ b/homeassistant/components/camera/blink.py @@ -76,6 +76,6 @@ def check_for_motion(self): return self.data.camera_thumbs[self._name] def camera_image(self): - """Return a still image reponse from the camera.""" + """Return a still image response from the camera.""" self.request_image() return self.response.content diff --git a/homeassistant/components/camera/doorbird.py b/homeassistant/components/camera/doorbird.py new file mode 100644 index 00000000000000..cf6b6b2871f2b0 --- /dev/null +++ b/homeassistant/components/camera/doorbird.py @@ -0,0 +1,90 @@ +"""Support for viewing the camera feed from a DoorBird video doorbell.""" + +import asyncio +import datetime +import logging +import voluptuous as vol + +import aiohttp +import async_timeout + +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.components.doorbird import DOMAIN as DOORBIRD_DOMAIN +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +DEPENDENCIES = ['doorbird'] + +_CAMERA_LIVE = "DoorBird Live" +_CAMERA_LAST_VISITOR = "DoorBird Last Ring" +_LIVE_INTERVAL = datetime.timedelta(seconds=1) +_LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=1) +_LOGGER = logging.getLogger(__name__) +_TIMEOUT = 10 # seconds + +CONF_SHOW_LAST_VISITOR = 'last_visitor' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_SHOW_LAST_VISITOR, default=False): cv.boolean +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the DoorBird camera platform.""" + device = hass.data.get(DOORBIRD_DOMAIN) + + _LOGGER.debug("Adding DoorBird camera %s", _CAMERA_LIVE) + entities = [DoorBirdCamera(device.live_image_url, _CAMERA_LIVE, + _LIVE_INTERVAL)] + + if config.get(CONF_SHOW_LAST_VISITOR): + _LOGGER.debug("Adding DoorBird camera %s", _CAMERA_LAST_VISITOR) + entities.append(DoorBirdCamera(device.history_image_url(1), + _CAMERA_LAST_VISITOR, + _LAST_VISITOR_INTERVAL)) + + async_add_devices(entities) + _LOGGER.info("Added DoorBird camera(s)") + + +class DoorBirdCamera(Camera): + """The camera on a DoorBird device.""" + + def __init__(self, url, name, interval=None): + """Initialize the camera on a DoorBird device.""" + self._url = url + self._name = name + self._last_image = None + self._interval = interval or datetime.timedelta + self._last_update = datetime.datetime.min + super().__init__() + + @property + def name(self): + """Get the name of the camera.""" + return self._name + + @asyncio.coroutine + def async_camera_image(self): + """Pull a still image from the camera.""" + now = datetime.datetime.now() + + if self._last_image and now - self._last_update < self._interval: + return self._last_image + + try: + websession = async_get_clientsession(self.hass) + + with async_timeout.timeout(_TIMEOUT, loop=self.hass.loop): + response = yield from websession.get(self._url) + + self._last_image = yield from response.read() + self._last_update = now + return self._last_image + except asyncio.TimeoutError: + _LOGGER.error("Camera image timed out") + return self._last_image + except aiohttp.ClientError as error: + _LOGGER.error("Error getting camera image: %s", error) + return self._last_image diff --git a/homeassistant/components/camera/ffmpeg.py b/homeassistant/components/camera/ffmpeg.py index 8ca72a09261405..1bbd263e585554 100644 --- a/homeassistant/components/camera/ffmpeg.py +++ b/homeassistant/components/camera/ffmpeg.py @@ -55,9 +55,9 @@ def async_camera_image(self): from haffmpeg import ImageFrame, IMAGE_JPEG ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) - image = yield from ffmpeg.get_image( + image = yield from asyncio.shield(ffmpeg.get_image( self._input, output_format=IMAGE_JPEG, - extra_cmd=self._extra_arguments) + extra_cmd=self._extra_arguments), loop=self.hass.loop) return image @asyncio.coroutine diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py index 3f2761e332a5c0..3cc391eae33f53 100644 --- a/homeassistant/components/camera/foscam.py +++ b/homeassistant/components/camera/foscam.py @@ -59,7 +59,7 @@ def __init__(self, device_info): self._password, verbose=False) def camera_image(self): - """Return a still image reponse from the camera.""" + """Return a still image response from the camera.""" # Send the request to snap a picture and return raw jpg data # Handle exception if host is not reachable or url failed result, response = self._foscam_session.snap_picture_2() diff --git a/homeassistant/components/camera/onvif.py b/homeassistant/components/camera/onvif.py index 711eb75a7445e7..8f30d9c8b8fdb9 100644 --- a/homeassistant/components/camera/onvif.py +++ b/homeassistant/components/camera/onvif.py @@ -78,9 +78,9 @@ def async_camera_image(self): ffmpeg = ImageFrame( self.hass.data[DATA_FFMPEG].binary, loop=self.hass.loop) - image = yield from ffmpeg.get_image( + image = yield from asyncio.shield(ffmpeg.get_image( self._input, output_format=IMAGE_JPEG, - extra_cmd=self._ffmpeg_arguments) + extra_cmd=self._ffmpeg_arguments), loop=self.hass.loop) return image @asyncio.coroutine diff --git a/homeassistant/components/camera/skybell.py b/homeassistant/components/camera/skybell.py new file mode 100644 index 00000000000000..be3504dab78b3e --- /dev/null +++ b/homeassistant/components/camera/skybell.py @@ -0,0 +1,67 @@ +""" +Camera support for the Skybell HD Doorbell. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.skybell/ +""" +from datetime import timedelta +import logging + +import requests + +from homeassistant.components.camera import Camera +from homeassistant.components.skybell import ( + DOMAIN as SKYBELL_DOMAIN, SkybellDevice) + +DEPENDENCIES = ['skybell'] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=90) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the platform for a Skybell device.""" + skybell = hass.data.get(SKYBELL_DOMAIN) + + sensors = [] + for device in skybell.get_devices(): + sensors.append(SkybellCamera(device)) + + add_devices(sensors, True) + + +class SkybellCamera(SkybellDevice, Camera): + """A camera implementation for Skybell devices.""" + + def __init__(self, device): + """Initialize a camera for a Skybell device.""" + SkybellDevice.__init__(self, device) + Camera.__init__(self) + self._name = self._device.name + self._url = None + self._response = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + def camera_image(self): + """Get the latest camera image.""" + super().update() + + if self._url != self._device.image: + self._url = self._device.image + + try: + self._response = requests.get( + self._url, stream=True, timeout=10) + except requests.HTTPError as err: + _LOGGER.warning("Failed to get camera image: %s", err) + self._response = None + + if not self._response: + return None + + return self._response.content diff --git a/homeassistant/components/camera/synology.py b/homeassistant/components/camera/synology.py index 90dfa58d8c54a5..fca9cbbc7a5f30 100644 --- a/homeassistant/components/camera/synology.py +++ b/homeassistant/components/camera/synology.py @@ -7,44 +7,25 @@ import asyncio import logging +import requests import voluptuous as vol -import aiohttp -import async_timeout - from homeassistant.const import ( CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_URL, CONF_WHITELIST, CONF_VERIFY_SSL, CONF_TIMEOUT) from homeassistant.components.camera import ( Camera, PLATFORM_SCHEMA) from homeassistant.helpers.aiohttp_client import ( - async_get_clientsession, async_create_clientsession, - async_aiohttp_proxy_web) + async_aiohttp_proxy_web, + async_get_clientsession) import homeassistant.helpers.config_validation as cv -from homeassistant.util.async import run_coroutine_threadsafe + +REQUIREMENTS = ['py-synology==0.1.5'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Synology Camera' -DEFAULT_STREAM_ID = '0' DEFAULT_TIMEOUT = 5 -CONF_CAMERA_NAME = 'camera_name' -CONF_STREAM_ID = 'stream_id' - -QUERY_CGI = 'query.cgi' -QUERY_API = 'SYNO.API.Info' -AUTH_API = 'SYNO.API.Auth' -CAMERA_API = 'SYNO.SurveillanceStation.Camera' -STREAMING_API = 'SYNO.SurveillanceStation.VideoStream' -SESSION_ID = '0' - -WEBAPI_PATH = '/webapi/' -AUTH_PATH = 'auth.cgi' -CAMERA_PATH = 'camera.cgi' -STREAMING_PATH = 'SurveillanceStation/videoStreaming.cgi' -CONTENT_TYPE_HEADER = 'Content-Type' - -SYNO_API_URL = '{0}{1}{2}' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -62,189 +43,90 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up a Synology IP Camera.""" verify_ssl = config.get(CONF_VERIFY_SSL) timeout = config.get(CONF_TIMEOUT) - websession_init = async_get_clientsession(hass, verify_ssl) - - # Determine API to use for authentication - syno_api_url = SYNO_API_URL.format( - config.get(CONF_URL), WEBAPI_PATH, QUERY_CGI) - - query_payload = { - 'api': QUERY_API, - 'method': 'Query', - 'version': '1', - 'query': 'SYNO.' - } - try: - with async_timeout.timeout(timeout, loop=hass.loop): - query_req = yield from websession_init.get( - syno_api_url, - params=query_payload - ) - - # Skip content type check because Synology doesn't return JSON with - # right content type - query_resp = yield from query_req.json(content_type=None) - auth_path = query_resp['data'][AUTH_API]['path'] - camera_api = query_resp['data'][CAMERA_API]['path'] - camera_path = query_resp['data'][CAMERA_API]['path'] - streaming_path = query_resp['data'][STREAMING_API]['path'] - - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.exception("Error on %s", syno_api_url) - return False - # Authticate to NAS to get a session id - syno_auth_url = SYNO_API_URL.format( - config.get(CONF_URL), WEBAPI_PATH, auth_path) - - session_id = yield from get_session_id( - hass, - websession_init, - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), - syno_auth_url, - timeout - ) - - # init websession - websession = async_create_clientsession( - hass, verify_ssl, cookies={'id': session_id}) - - # Use SessionID to get cameras in system - syno_camera_url = SYNO_API_URL.format( - config.get(CONF_URL), WEBAPI_PATH, camera_api) - - camera_payload = { - 'api': CAMERA_API, - 'method': 'List', - 'version': '1' - } try: - with async_timeout.timeout(timeout, loop=hass.loop): - camera_req = yield from websession.get( - syno_camera_url, - params=camera_payload - ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.exception("Error on %s", syno_camera_url) + from synology.surveillance_station import SurveillanceStation + surveillance = SurveillanceStation( + config.get(CONF_URL), + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + verify_ssl=verify_ssl, + timeout=timeout + ) + except (requests.exceptions.RequestException, ValueError): + _LOGGER.exception("Error when initializing SurveillanceStation") return False - camera_resp = yield from camera_req.json(content_type=None) - cameras = camera_resp['data']['cameras'] + cameras = surveillance.get_all_cameras() # add cameras devices = [] for camera in cameras: if not config.get(CONF_WHITELIST): - camera_id = camera['id'] - snapshot_path = camera['snapshot_path'] - - device = SynologyCamera( - hass, websession, config, camera_id, camera['name'], - snapshot_path, streaming_path, camera_path, auth_path, timeout - ) + device = SynologyCamera(surveillance, camera.camera_id, verify_ssl) devices.append(device) async_add_devices(devices) -@asyncio.coroutine -def get_session_id(hass, websession, username, password, login_url, timeout): - """Get a session id.""" - auth_payload = { - 'api': AUTH_API, - 'method': 'Login', - 'version': '2', - 'account': username, - 'passwd': password, - 'session': 'SurveillanceStation', - 'format': 'sid' - } - try: - with async_timeout.timeout(timeout, loop=hass.loop): - auth_req = yield from websession.get( - login_url, - params=auth_payload - ) - auth_resp = yield from auth_req.json(content_type=None) - return auth_resp['data']['sid'] - - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.exception("Error on %s", login_url) - return False - - class SynologyCamera(Camera): """An implementation of a Synology NAS based IP camera.""" - def __init__(self, hass, websession, config, camera_id, - camera_name, snapshot_path, streaming_path, camera_path, - auth_path, timeout): + def __init__(self, surveillance, camera_id, verify_ssl): """Initialize a Synology Surveillance Station camera.""" super().__init__() - self.hass = hass - self._websession = websession - self._name = camera_name - self._synology_url = config.get(CONF_URL) - self._camera_name = config.get(CONF_CAMERA_NAME) - self._stream_id = config.get(CONF_STREAM_ID) + self._surveillance = surveillance self._camera_id = camera_id - self._snapshot_path = snapshot_path - self._streaming_path = streaming_path - self._camera_path = camera_path - self._auth_path = auth_path - self._timeout = timeout + self._verify_ssl = verify_ssl + self._camera = self._surveillance.get_camera(camera_id) + self._motion_setting = self._surveillance.get_motion_setting(camera_id) + self.is_streaming = self._camera.is_enabled def camera_image(self): """Return bytes of camera image.""" - return run_coroutine_threadsafe( - self.async_camera_image(), self.hass.loop).result() - - @asyncio.coroutine - def async_camera_image(self): - """Return a still image response from the camera.""" - image_url = SYNO_API_URL.format( - self._synology_url, WEBAPI_PATH, self._camera_path) - - image_payload = { - 'api': CAMERA_API, - 'method': 'GetSnapshot', - 'version': '1', - 'cameraId': self._camera_id - } - try: - with async_timeout.timeout(self._timeout, loop=self.hass.loop): - response = yield from self._websession.get( - image_url, - params=image_payload - ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Error fetching %s", image_url) - return None - - image = yield from response.read() - - return image + return self._surveillance.get_camera_image(self._camera_id) @asyncio.coroutine def handle_async_mjpeg_stream(self, request): """Return a MJPEG stream image response directly from the camera.""" - streaming_url = SYNO_API_URL.format( - self._synology_url, WEBAPI_PATH, self._streaming_path) - - streaming_payload = { - 'api': STREAMING_API, - 'method': 'Stream', - 'version': '1', - 'cameraId': self._camera_id, - 'format': 'mjpeg' - } - stream_coro = self._websession.get( - streaming_url, params=streaming_payload) + streaming_url = self._camera.video_stream_url + + websession = async_get_clientsession(self.hass, self._verify_ssl) + stream_coro = websession.get(streaming_url) yield from async_aiohttp_proxy_web(self.hass, request, stream_coro) @property def name(self): """Return the name of this device.""" - return self._name + return self._camera.name + + @property + def is_recording(self): + """Return true if the device is recording.""" + return self._camera.is_recording + + def should_poll(self): + """Update the recording state periodically.""" + return True + + def update(self): + """Update the status of the camera.""" + self._surveillance.update() + self._camera = self._surveillance.get_camera(self._camera.camera_id) + self._motion_setting = self._surveillance.get_motion_setting( + self._camera.camera_id) + self.is_streaming = self._camera.is_enabled + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return self._motion_setting.is_enabled + + def enable_motion_detection(self): + """Enable motion detection in the camera.""" + self._surveillance.enable_motion_detection(self._camera_id) + + def disable_motion_detection(self): + """Disable motion detection in camera.""" + self._surveillance.disable_motion_detection(self._camera_id) diff --git a/homeassistant/components/camera/usps.py b/homeassistant/components/camera/usps.py index 545ea9798de4a9..6c76d0d66d8507 100644 --- a/homeassistant/components/camera/usps.py +++ b/homeassistant/components/camera/usps.py @@ -77,7 +77,7 @@ def name(self): def model(self): """Return date of mail as model.""" try: - return 'Date: {}'.format(self._usps.mail[0]['date']) + return 'Date: {}'.format(str(self._usps.mail[0]['date'])) except IndexError: return None diff --git a/homeassistant/components/camera/uvc.py b/homeassistant/components/camera/uvc.py index 3203a10b39125e..685b6d643642f3 100644 --- a/homeassistant/components/camera/uvc.py +++ b/homeassistant/components/camera/uvc.py @@ -14,7 +14,7 @@ from homeassistant.components.camera import Camera, PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['uvcclient==0.10.0'] +REQUIREMENTS = ['uvcclient==0.10.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/camera/yi.py b/homeassistant/components/camera/yi.py new file mode 100644 index 00000000000000..8e41429baeaa26 --- /dev/null +++ b/homeassistant/components/camera/yi.py @@ -0,0 +1,137 @@ +""" +This component provides support for Xiaomi Cameras (HiSilicon Hi3518e V200). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.yi/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA +from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.const import (CONF_HOST, CONF_NAME, CONF_PATH, + CONF_PASSWORD, CONF_PORT, CONF_USERNAME) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream + +DEPENDENCIES = ['ffmpeg'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_BRAND = 'YI Home Camera' +DEFAULT_PASSWORD = '' +DEFAULT_PATH = '/tmp/sd/record' +DEFAULT_PORT = 21 +DEFAULT_USERNAME = 'root' + +CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, + vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up a Yi Camera.""" + _LOGGER.debug('Received configuration: %s', config) + async_add_devices([YiCamera(hass, config)], True) + + +class YiCamera(Camera): + """Define an implementation of a Yi Camera.""" + + def __init__(self, hass, config): + """Initialize.""" + super().__init__() + self._extra_arguments = config.get(CONF_FFMPEG_ARGUMENTS) + self._last_image = None + self._last_url = None + self._manager = hass.data[DATA_FFMPEG] + self._name = config.get(CONF_NAME) + self.host = config.get(CONF_HOST) + self.port = config.get(CONF_PORT) + self.path = config.get(CONF_PATH) + self.user = config.get(CONF_USERNAME) + self.passwd = config.get(CONF_PASSWORD) + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def brand(self): + """Camera brand.""" + return DEFAULT_BRAND + + def get_latest_video_url(self): + """Retrieve the latest video file from the customized Yi FTP server.""" + from ftplib import FTP, error_perm + + ftp = FTP(self.host) + try: + ftp.login(self.user, self.passwd) + except error_perm as exc: + _LOGGER.error('There was an error while logging into the camera') + _LOGGER.debug(exc) + return False + + try: + ftp.cwd(self.path) + except error_perm as exc: + _LOGGER.error('Unable to find path: %s', self.path) + _LOGGER.debug(exc) + return False + + dirs = [d for d in ftp.nlst() if '.' not in d] + if not dirs: + _LOGGER.warning("There don't appear to be any uploaded videos") + return False + + latest_dir = dirs[-1] + ftp.cwd(latest_dir) + videos = ftp.nlst() + if not videos: + _LOGGER.info('Video folder "%s" is empty; delaying', latest_dir) + return False + + return 'ftp://{0}:{1}@{2}:{3}{4}/{5}/{6}'.format( + self.user, self.passwd, self.host, self.port, self.path, + latest_dir, videos[-1]) + + @asyncio.coroutine + def async_camera_image(self): + """Return a still image response from the camera.""" + from haffmpeg import ImageFrame, IMAGE_JPEG + + url = yield from self.hass.async_add_job(self.get_latest_video_url) + if url != self._last_url: + ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) + self._last_image = yield from asyncio.shield(ffmpeg.get_image( + url, output_format=IMAGE_JPEG, + extra_cmd=self._extra_arguments), loop=self.hass.loop) + self._last_url = url + + return self._last_image + + @asyncio.coroutine + def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + from haffmpeg import CameraMjpeg + + stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop) + yield from stream.open_camera( + self._last_url, extra_cmd=self._extra_arguments) + + yield from async_aiohttp_proxy_stream( + self.hass, request, stream, + 'multipart/x-mixed-replace;boundary=ffserver') + yield from stream.close() diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 1f91930125494e..61f5773356f6c9 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -44,6 +44,12 @@ STATE_AUTO = 'auto' STATE_DRY = 'dry' STATE_FAN_ONLY = 'fan_only' +STATE_ECO = 'eco' +STATE_ELECTRIC = 'electric' +STATE_PERFORMANCE = 'performance' +STATE_HIGH_DEMAND = 'high_demand' +STATE_HEAT_PUMP = 'heat_pump' +STATE_GAS = 'gas' ATTR_CURRENT_TEMPERATURE = 'current_temperature' ATTR_MAX_TEMP = 'max_temp' @@ -147,7 +153,7 @@ def set_hold_mode(hass, hold_mode, entity_id=None): @bind_hass def set_aux_heat(hass, aux_heat, entity_id=None): - """Turn all or specified climate devices auxillary heater on.""" + """Turn all or specified climate devices auxiliary heater on.""" data = { ATTR_AUX_HEAT: aux_heat } @@ -230,24 +236,6 @@ def async_setup(hass, config): load_yaml_config_file, os.path.join(os.path.dirname(__file__), 'services.yaml')) - @asyncio.coroutine - def _async_update_climate(target_climate): - """Update climate entity after service stuff.""" - update_tasks = [] - for climate in target_climate: - if not climate.should_poll: - continue - - update_coro = hass.async_add_job( - climate.async_update_ha_state(True)) - if hasattr(climate, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro - - if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) - @asyncio.coroutine def async_away_mode_set_service(service): """Set away mode on target climate devices.""" @@ -255,13 +243,19 @@ def async_away_mode_set_service(service): away_mode = service.data.get(ATTR_AWAY_MODE) + update_tasks = [] for climate in target_climate: if away_mode: yield from climate.async_turn_away_mode_on() else: yield from climate.async_turn_away_mode_off() - yield from _async_update_climate(target_climate) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) + + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_AWAY_MODE, async_away_mode_set_service, @@ -275,10 +269,16 @@ def async_hold_mode_set_service(service): hold_mode = service.data.get(ATTR_HOLD_MODE) + update_tasks = [] for climate in target_climate: yield from climate.async_set_hold_mode(hold_mode) - yield from _async_update_climate(target_climate) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) + + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_HOLD_MODE, async_hold_mode_set_service, @@ -292,13 +292,19 @@ def async_aux_heat_set_service(service): aux_heat = service.data.get(ATTR_AUX_HEAT) + update_tasks = [] for climate in target_climate: if aux_heat: yield from climate.async_turn_aux_heat_on() else: yield from climate.async_turn_aux_heat_off() - yield from _async_update_climate(target_climate) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) + + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_AUX_HEAT, async_aux_heat_set_service, @@ -310,6 +316,7 @@ def async_temperature_set_service(service): """Set temperature on the target climate devices.""" target_climate = component.async_extract_from_service(service) + update_tasks = [] for climate in target_climate: kwargs = {} for value, temp in service.data.items(): @@ -324,7 +331,12 @@ def async_temperature_set_service(service): yield from climate.async_set_temperature(**kwargs) - yield from _async_update_climate(target_climate) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) + + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_TEMPERATURE, async_temperature_set_service, @@ -338,10 +350,15 @@ def async_humidity_set_service(service): humidity = service.data.get(ATTR_HUMIDITY) + update_tasks = [] for climate in target_climate: yield from climate.async_set_humidity(humidity) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) - yield from _async_update_climate(target_climate) + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_HUMIDITY, async_humidity_set_service, @@ -355,10 +372,15 @@ def async_fan_mode_set_service(service): fan = service.data.get(ATTR_FAN_MODE) + update_tasks = [] for climate in target_climate: yield from climate.async_set_fan_mode(fan) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) - yield from _async_update_climate(target_climate) + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_FAN_MODE, async_fan_mode_set_service, @@ -372,10 +394,15 @@ def async_operation_set_service(service): operation_mode = service.data.get(ATTR_OPERATION_MODE) + update_tasks = [] for climate in target_climate: yield from climate.async_set_operation_mode(operation_mode) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) - yield from _async_update_climate(target_climate) + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_OPERATION_MODE, async_operation_set_service, @@ -389,10 +416,15 @@ def async_swing_set_service(service): swing_mode = service.data.get(ATTR_SWING_MODE) + update_tasks = [] for climate in target_climate: yield from climate.async_set_swing_mode(swing_mode) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) - yield from _async_update_climate(target_climate) + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_SWING_MODE, async_swing_set_service, @@ -661,22 +693,22 @@ def async_set_hold_mode(self, hold_mode): return self.hass.async_add_job(self.set_hold_mode, hold_mode) def turn_aux_heat_on(self): - """Turn auxillary heater on.""" + """Turn auxiliary heater on.""" raise NotImplementedError() def async_turn_aux_heat_on(self): - """Turn auxillary heater on. + """Turn auxiliary heater on. This method must be run in the event loop and returns a coroutine. """ return self.hass.async_add_job(self.turn_aux_heat_on) def turn_aux_heat_off(self): - """Turn auxillary heater off.""" + """Turn auxiliary heater off.""" raise NotImplementedError() def async_turn_aux_heat_off(self): - """Turn auxillary heater off. + """Turn auxiliary heater off. This method must be run in the event loop and returns a coroutine. """ diff --git a/homeassistant/components/climate/demo.py b/homeassistant/components/climate/demo.py index 24b40af7eb1f86..377985aaa12f61 100644 --- a/homeassistant/components/climate/demo.py +++ b/homeassistant/components/climate/demo.py @@ -114,7 +114,7 @@ def current_hold_mode(self): @property def is_aux_heat_on(self): - """Return true if away mode is on.""" + """Return true if aux heat is on.""" return self._aux @property @@ -183,11 +183,11 @@ def set_hold_mode(self, hold): self.schedule_update_ha_state() def turn_aux_heat_on(self): - """Turn away auxillary heater on.""" + """Turn auxillary heater on.""" self._aux = True self.schedule_update_ha_state() def turn_aux_heat_off(self): - """Turn auxillary heater off.""" + """Turn auxiliary heater off.""" self._aux = False self.schedule_update_ha_state() diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py index 6780d3745f05e3..d6d92432730434 100644 --- a/homeassistant/components/climate/ecobee.py +++ b/homeassistant/components/climate/ecobee.py @@ -27,6 +27,7 @@ DEFAULT_RESUME_ALL = False TEMPERATURE_HOLD = 'temp' VACATION_HOLD = 'vacation' +AWAY_MODE = 'awayMode' DEPENDENCIES = ['ecobee'] @@ -144,20 +145,20 @@ def temperature_unit(self): @property def current_temperature(self): """Return the current temperature.""" - return self.thermostat['runtime']['actualTemperature'] / 10 + return self.thermostat['runtime']['actualTemperature'] / 10.0 @property def target_temperature_low(self): """Return the lower bound temperature we try to reach.""" if self.current_operation == STATE_AUTO: - return int(self.thermostat['runtime']['desiredHeat'] / 10) + return self.thermostat['runtime']['desiredHeat'] / 10.0 return None @property def target_temperature_high(self): """Return the upper bound temperature we try to reach.""" if self.current_operation == STATE_AUTO: - return int(self.thermostat['runtime']['desiredCool'] / 10) + return self.thermostat['runtime']['desiredCool'] / 10.0 return None @property @@ -166,9 +167,9 @@ def target_temperature(self): if self.current_operation == STATE_AUTO: return None if self.current_operation == STATE_HEAT: - return int(self.thermostat['runtime']['desiredHeat'] / 10) + return self.thermostat['runtime']['desiredHeat'] / 10.0 elif self.current_operation == STATE_COOL: - return int(self.thermostat['runtime']['desiredCool'] / 10) + return self.thermostat['runtime']['desiredCool'] / 10.0 return None @property @@ -186,6 +187,11 @@ def fan(self): @property def current_hold_mode(self): """Return current hold mode.""" + mode = self._current_hold_mode + return None if mode == AWAY_MODE else mode + + @property + def _current_hold_mode(self): events = self.thermostat['events'] for event in events: if event['running']: @@ -195,8 +201,8 @@ def current_hold_mode(self): int(event['startDate'][0:4]) <= 1: # A temporary hold from away climate is a hold return 'away' - # A permanent hold from away climate is away_mode - return None + # A permanent hold from away climate + return AWAY_MODE elif event['holdClimateRef'] != "": # Any other hold based on climate return event['holdClimateRef'] @@ -269,7 +275,7 @@ def device_state_attributes(self): @property def is_away_mode_on(self): """Return true if away mode is on.""" - return self.current_hold_mode == 'away' + return self._current_hold_mode == AWAY_MODE @property def is_aux_heat_on(self): @@ -277,12 +283,17 @@ def is_aux_heat_on(self): return 'auxHeat' in self.thermostat['equipmentStatus'] def turn_away_mode_on(self): - """Turn away on.""" - self.set_hold_mode('away') + """Turn away mode on by setting it on away hold indefinitely.""" + if self._current_hold_mode != AWAY_MODE: + self.data.ecobee.set_climate_hold(self.thermostat_index, 'away', + 'indefinite') + self.update_without_throttle = True def turn_away_mode_off(self): """Turn away off.""" - self.set_hold_mode(None) + if self._current_hold_mode == AWAY_MODE: + self.data.ecobee.resume_program(self.thermostat_index) + self.update_without_throttle = True def set_hold_mode(self, hold_mode): """Set hold mode (away, home, temp, sleep, etc.).""" @@ -299,7 +310,7 @@ def set_hold_mode(self, hold_mode): self.data.ecobee.resume_program(self.thermostat_index) else: if hold_mode == TEMPERATURE_HOLD: - self.set_temp_hold(int(self.current_temperature)) + self.set_temp_hold(self.current_temperature) else: self.data.ecobee.set_climate_hold( self.thermostat_index, hold_mode, self.hold_preference()) @@ -325,15 +336,11 @@ def set_temp_hold(self, temp): elif self.current_operation == STATE_COOL: heat_temp = temp - 20 cool_temp = temp - - self.data.ecobee.set_hold_temp(self.thermostat_index, cool_temp, - heat_temp, self.hold_preference()) - _LOGGER.debug("Setting ecobee hold_temp to: low=%s, is=%s, " - "cool=%s, is=%s", heat_temp, isinstance( - heat_temp, (int, float)), cool_temp, - isinstance(cool_temp, (int, float))) - - self.update_without_throttle = True + else: + # In auto mode set temperature between + heat_temp = temp - 10 + cool_temp = temp + 10 + self.set_auto_temp_hold(heat_temp, cool_temp) def set_temperature(self, **kwargs): """Set new target temperature.""" @@ -343,9 +350,9 @@ def set_temperature(self, **kwargs): if self.current_operation == STATE_AUTO and low_temp is not None \ and high_temp is not None: - self.set_auto_temp_hold(int(low_temp), int(high_temp)) + self.set_auto_temp_hold(low_temp, high_temp) elif temp is not None: - self.set_temp_hold(int(temp)) + self.set_temp_hold(temp) else: _LOGGER.error( "Missing valid arguments for set_temperature in %s", kwargs) @@ -364,7 +371,7 @@ def set_fan_min_on_time(self, fan_min_on_time): def resume_program(self, resume_all): """Resume the thermostat schedule program.""" self.data.ecobee.resume_program( - self.thermostat_index, str(resume_all).lower()) + self.thermostat_index, 'true' if resume_all else 'false') self.update_without_throttle = True def hold_preference(self): diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index ff13dd48cac402..d70890317fd131 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -17,7 +17,7 @@ import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-eq3bt==0.1.5'] +REQUIREMENTS = ['python-eq3bt==0.1.6'] _LOGGER = logging.getLogger(__name__) @@ -164,4 +164,8 @@ def device_state_attributes(self): def update(self): """Update the data from the thermostat.""" - self._thermostat.update() + from bluepy.btle import BTLEException + try: + self._thermostat.update() + except BTLEException as ex: + _LOGGER.warning("Updating the state failed: %s", ex) diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index 688ded5e7c4bb2..784d8a4ed28402 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -14,6 +14,8 @@ import homeassistant.helpers.config_validation as cv CONF_SETPOINT_ADDRESS = 'setpoint_address' +CONF_SETPOINT_SHIFT_ADDRESS = 'setpoint_shift_address' +CONF_SETPOINT_SHIFT_STATE_ADDRESS = 'setpoint_shift_state_address' CONF_TEMPERATURE_ADDRESS = 'temperature_address' CONF_TARGET_TEMPERATURE_ADDRESS = 'target_temperature_address' CONF_OPERATION_MODE_ADDRESS = 'operation_mode_address' @@ -33,6 +35,8 @@ vol.Required(CONF_SETPOINT_ADDRESS): cv.string, vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string, vol.Required(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string, + vol.Optional(CONF_SETPOINT_SHIFT_ADDRESS): cv.string, + vol.Optional(CONF_SETPOINT_SHIFT_STATE_ADDRESS): cv.string, vol.Optional(CONF_OPERATION_MODE_ADDRESS): cv.string, vol.Optional(CONF_OPERATION_MODE_STATE_ADDRESS): cv.string, vol.Optional(CONF_CONTROLLER_STATUS_ADDRESS): cv.string, @@ -44,7 +48,7 @@ @asyncio.coroutine -def async_setup_platform(hass, config, add_devices, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up climate(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -52,25 +56,25 @@ def async_setup_platform(hass, config, add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) + async_add_devices_config(hass, config, async_add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up climates for KNX platform configured within plattform.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXClimate(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): +def async_add_devices_config(hass, config, async_add_devices): """Set up climate for KNX platform configured within plattform.""" import xknx climate = xknx.devices.Climate( @@ -82,6 +86,10 @@ def async_add_devices_config(hass, config, add_devices): CONF_TARGET_TEMPERATURE_ADDRESS), group_address_setpoint=config.get( CONF_SETPOINT_ADDRESS), + group_address_setpoint_shift=config.get( + CONF_SETPOINT_SHIFT_ADDRESS), + group_address_setpoint_shift_state=config.get( + CONF_SETPOINT_SHIFT_STATE_ADDRESS), group_address_operation_mode=config.get( CONF_OPERATION_MODE_ADDRESS), group_address_operation_mode_state=config.get( @@ -97,7 +105,7 @@ def async_add_devices_config(hass, config, add_devices): group_address_operation_mode_comfort=config.get( CONF_OPERATION_MODE_COMFORT_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(climate) - add_devices([KNXClimate(hass, climate)]) + async_add_devices([KNXClimate(hass, climate)]) class KNXClimate(ClimateDevice): @@ -140,13 +148,29 @@ def temperature_unit(self): @property def current_temperature(self): """Return the current temperature.""" - return self.device.temperature + return self.device.temperature.value @property def target_temperature(self): """Return the temperature we try to reach.""" - if self.device.supports_target_temperature: - return self.device.target_temperature + return self.device.target_temperature_comfort + + @property + def target_temperature_high(self): + """Return the highbound target temperature we try to reach.""" + if self.device.target_temperature_comfort: + return max( + self.device.target_temperature_comfort, + self.device.target_temperature.value) + return None + + @property + def target_temperature_low(self): + """Return the lowbound target temperature we try to reach.""" + if self.device.target_temperature_comfort: + return min( + self.device.target_temperature_comfort, + self.device.target_temperature.value) return None @asyncio.coroutine @@ -155,8 +179,8 @@ def async_set_temperature(self, **kwargs): temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - if self.device.supports_target_temperature: - yield from self.device.set_target_temperature(temperature) + yield from self.device.set_target_temperature_comfort(temperature) + yield from self.async_update_ha_state() @property def current_operation(self): diff --git a/homeassistant/components/climate/mqtt.py b/homeassistant/components/climate/mqtt.py new file mode 100644 index 00000000000000..de6ac7a02276e4 --- /dev/null +++ b/homeassistant/components/climate/mqtt.py @@ -0,0 +1,485 @@ +""" +Support for MQTT climate devices. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/climate.mqtt/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.core import callback +import homeassistant.components.mqtt as mqtt + +from homeassistant.components.climate import ( + STATE_HEAT, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, ClimateDevice, + PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA, STATE_AUTO, + ATTR_OPERATION_MODE) +from homeassistant.const import ( + STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME) +from homeassistant.components.mqtt import (CONF_QOS, CONF_RETAIN, + MQTT_BASE_PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv +from homeassistant.components.fan import (SPEED_LOW, SPEED_MEDIUM, + SPEED_HIGH) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['mqtt'] + +DEFAULT_NAME = 'MQTT HVAC' + +CONF_POWER_COMMAND_TOPIC = 'power_command_topic' +CONF_POWER_STATE_TOPIC = 'power_state_topic' +CONF_MODE_COMMAND_TOPIC = 'mode_command_topic' +CONF_MODE_STATE_TOPIC = 'mode_state_topic' +CONF_TEMPERATURE_COMMAND_TOPIC = 'temperature_command_topic' +CONF_TEMPERATURE_STATE_TOPIC = 'temperature_state_topic' +CONF_FAN_MODE_COMMAND_TOPIC = 'fan_mode_command_topic' +CONF_FAN_MODE_STATE_TOPIC = 'fan_mode_state_topic' +CONF_SWING_MODE_COMMAND_TOPIC = 'swing_mode_command_topic' +CONF_SWING_MODE_STATE_TOPIC = 'swing_mode_state_topic' +CONF_AWAY_MODE_COMMAND_TOPIC = 'away_mode_command_topic' +CONF_AWAY_MODE_STATE_TOPIC = 'away_mode_state_topic' +CONF_HOLD_COMMAND_TOPIC = 'hold_command_topic' +CONF_HOLD_STATE_TOPIC = 'hold_state_topic' +CONF_AUX_COMMAND_TOPIC = 'aux_command_topic' +CONF_AUX_STATE_TOPIC = 'aux_state_topic' + +CONF_CURRENT_TEMPERATURE_TOPIC = 'current_temperature_topic' + +CONF_PAYLOAD_ON = 'payload_on' +CONF_PAYLOAD_OFF = 'payload_off' + +CONF_FAN_MODE_LIST = 'fan_modes' +CONF_MODE_LIST = 'modes' +CONF_SWING_MODE_LIST = 'swing_modes' +CONF_INITIAL = 'initial' +CONF_SEND_IF_OFF = 'send_if_off' + +SCHEMA_BASE = CLIMATE_PLATFORM_SCHEMA.extend(MQTT_BASE_PLATFORM_SCHEMA.schema) +PLATFORM_SCHEMA = SCHEMA_BASE.extend({ + vol.Optional(CONF_POWER_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_TEMPERATURE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_FAN_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_SWING_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_AWAY_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_HOLD_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_AUX_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_POWER_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_TEMPERATURE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_FAN_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_SWING_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_AWAY_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_HOLD_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_AUX_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_CURRENT_TEMPERATURE_TOPIC): + mqtt.valid_subscribe_topic, + vol.Optional(CONF_FAN_MODE_LIST, + default=[STATE_AUTO, SPEED_LOW, + SPEED_MEDIUM, SPEED_HIGH]): cv.ensure_list, + vol.Optional(CONF_SWING_MODE_LIST, + default=[STATE_ON, STATE_OFF]): cv.ensure_list, + vol.Optional(CONF_MODE_LIST, + default=[STATE_AUTO, STATE_OFF, STATE_COOL, STATE_HEAT, + STATE_DRY, STATE_FAN_ONLY]): cv.ensure_list, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_INITIAL, default=21): cv.positive_int, + vol.Optional(CONF_SEND_IF_OFF, default=True): cv.boolean, + vol.Optional(CONF_PAYLOAD_ON, default="ON"): cv.string, + vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the MQTT climate devices.""" + async_add_devices([ + MqttClimate( + hass, + config.get(CONF_NAME), + { + key: config.get(key) for key in ( + CONF_POWER_COMMAND_TOPIC, + CONF_MODE_COMMAND_TOPIC, + CONF_TEMPERATURE_COMMAND_TOPIC, + CONF_FAN_MODE_COMMAND_TOPIC, + CONF_SWING_MODE_COMMAND_TOPIC, + CONF_AWAY_MODE_COMMAND_TOPIC, + CONF_HOLD_COMMAND_TOPIC, + CONF_AUX_COMMAND_TOPIC, + CONF_POWER_STATE_TOPIC, + CONF_MODE_STATE_TOPIC, + CONF_TEMPERATURE_STATE_TOPIC, + CONF_FAN_MODE_STATE_TOPIC, + CONF_SWING_MODE_STATE_TOPIC, + CONF_AWAY_MODE_STATE_TOPIC, + CONF_HOLD_STATE_TOPIC, + CONF_AUX_STATE_TOPIC, + CONF_CURRENT_TEMPERATURE_TOPIC + ) + }, + config.get(CONF_QOS), + config.get(CONF_RETAIN), + config.get(CONF_MODE_LIST), + config.get(CONF_FAN_MODE_LIST), + config.get(CONF_SWING_MODE_LIST), + config.get(CONF_INITIAL), + False, None, SPEED_LOW, + STATE_OFF, STATE_OFF, False, + config.get(CONF_SEND_IF_OFF), + config.get(CONF_PAYLOAD_ON), + config.get(CONF_PAYLOAD_OFF)) + ]) + + +class MqttClimate(ClimateDevice): + """Representation of a demo climate device.""" + + def __init__(self, hass, name, topic, qos, retain, mode_list, + fan_mode_list, swing_mode_list, target_temperature, away, + hold, current_fan_mode, current_swing_mode, + current_operation, aux, send_if_off, payload_on, + payload_off): + """Initialize the climate device.""" + self.hass = hass + self._name = name + self._topic = topic + self._qos = qos + self._retain = retain + self._target_temperature = target_temperature + self._unit_of_measurement = hass.config.units.temperature_unit + self._away = away + self._hold = hold + self._current_temperature = None + self._current_fan_mode = current_fan_mode + self._current_operation = current_operation + self._aux = aux + self._current_swing_mode = current_swing_mode + self._fan_list = fan_mode_list + self._operation_list = mode_list + self._swing_list = swing_mode_list + self._target_temperature_step = 1 + self._send_if_off = send_if_off + self._payload_on = payload_on + self._payload_off = payload_off + + def async_added_to_hass(self): + """Handle being added to home assistant.""" + @callback + def handle_current_temp_received(topic, payload, qos): + """Handle current temperature coming via MQTT.""" + try: + self._current_temperature = float(payload) + self.async_schedule_update_ha_state() + except ValueError: + _LOGGER.error("Could not parse temperature from %s", payload) + + if self._topic[CONF_CURRENT_TEMPERATURE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_CURRENT_TEMPERATURE_TOPIC], + handle_current_temp_received, self._qos) + + @callback + def handle_mode_received(topic, payload, qos): + """Handle receiving mode via MQTT.""" + if payload not in self._operation_list: + _LOGGER.error("Invalid mode: %s", payload) + else: + self._current_operation = payload + self.async_schedule_update_ha_state() + + if self._topic[CONF_MODE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_MODE_STATE_TOPIC], + handle_mode_received, self._qos) + + @callback + def handle_temperature_received(topic, payload, qos): + """Handle target temperature coming via MQTT.""" + try: + self._target_temperature = float(payload) + self.async_schedule_update_ha_state() + except ValueError: + _LOGGER.error("Could not parse temperature from %s", payload) + + if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_TEMPERATURE_STATE_TOPIC], + handle_temperature_received, self._qos) + + @callback + def handle_fan_mode_received(topic, payload, qos): + """Handle receiving fan mode via MQTT.""" + if payload not in self._fan_list: + _LOGGER.error("Invalid fan mode: %s", payload) + else: + self._current_fan_mode = payload + self.async_schedule_update_ha_state() + + if self._topic[CONF_FAN_MODE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_FAN_MODE_STATE_TOPIC], + handle_fan_mode_received, self._qos) + + @callback + def handle_swing_mode_received(topic, payload, qos): + """Handle receiving swing mode via MQTT.""" + if payload not in self._swing_list: + _LOGGER.error("Invalid swing mode: %s", payload) + else: + self._current_swing_mode = payload + self.async_schedule_update_ha_state() + + if self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_SWING_MODE_STATE_TOPIC], + handle_swing_mode_received, self._qos) + + @callback + def handle_away_mode_received(topic, payload, qos): + """Handle receiving away mode via MQTT.""" + if payload == self._payload_on: + self._away = True + elif payload == self._payload_off: + self._away = False + else: + _LOGGER.error("Invalid away mode: %s", payload) + + self.async_schedule_update_ha_state() + + if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_AWAY_MODE_STATE_TOPIC], + handle_away_mode_received, self._qos) + + @callback + def handle_aux_mode_received(topic, payload, qos): + """Handle receiving aux mode via MQTT.""" + if payload == self._payload_on: + self._aux = True + elif payload == self._payload_off: + self._aux = False + else: + _LOGGER.error("Invalid aux mode: %s", payload) + + self.async_schedule_update_ha_state() + + if self._topic[CONF_AUX_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_AUX_STATE_TOPIC], + handle_aux_mode_received, self._qos) + + @callback + def handle_hold_mode_received(topic, payload, qos): + """Handle receiving hold mode via MQTT.""" + self._hold = payload + self.async_schedule_update_ha_state() + + if self._topic[CONF_HOLD_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_HOLD_STATE_TOPIC], + handle_hold_mode_received, self._qos) + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the climate device.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return self._current_operation + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return self._operation_list + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return self._target_temperature_step + + @property + def is_away_mode_on(self): + """Return if away mode is on.""" + return self._away + + @property + def current_hold_mode(self): + """Return hold mode setting.""" + return self._hold + + @property + def is_aux_heat_on(self): + """Return true if away mode is on.""" + return self._aux + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return self._current_fan_mode + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return self._fan_list + + @asyncio.coroutine + def async_set_temperature(self, **kwargs): + """Set new target temperatures.""" + if kwargs.get(ATTR_OPERATION_MODE) is not None: + operation_mode = kwargs.get(ATTR_OPERATION_MODE) + yield from self.async_set_operation_mode(operation_mode) + + if kwargs.get(ATTR_TEMPERATURE) is not None: + if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is None: + # optimistic mode + self._target_temperature = kwargs.get(ATTR_TEMPERATURE) + + if self._send_if_off or self._current_operation != STATE_OFF: + mqtt.async_publish( + self.hass, self._topic[CONF_TEMPERATURE_COMMAND_TOPIC], + kwargs.get(ATTR_TEMPERATURE), self._qos, self._retain) + + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_set_swing_mode(self, swing_mode): + """Set new swing mode.""" + if self._send_if_off or self._current_operation != STATE_OFF: + mqtt.async_publish( + self.hass, self._topic[CONF_SWING_MODE_COMMAND_TOPIC], + swing_mode, self._qos, self._retain) + + if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None: + self._current_swing_mode = swing_mode + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_set_fan_mode(self, fan): + """Set new target temperature.""" + if self._send_if_off or self._current_operation != STATE_OFF: + mqtt.async_publish( + self.hass, self._topic[CONF_FAN_MODE_COMMAND_TOPIC], + fan, self._qos, self._retain) + + if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None: + self._current_fan_mode = fan + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_set_operation_mode(self, operation_mode) -> None: + """Set new operation mode.""" + if self._topic[CONF_POWER_COMMAND_TOPIC] is not None: + if (self._current_operation == STATE_OFF and + operation_mode != STATE_OFF): + mqtt.async_publish( + self.hass, self._topic[CONF_POWER_COMMAND_TOPIC], + self._payload_on, self._qos, self._retain) + elif (self._current_operation != STATE_OFF and + operation_mode == STATE_OFF): + mqtt.async_publish( + self.hass, self._topic[CONF_POWER_COMMAND_TOPIC], + self._payload_off, self._qos, self._retain) + + if self._topic[CONF_MODE_COMMAND_TOPIC] is not None: + mqtt.async_publish( + self.hass, self._topic[CONF_MODE_COMMAND_TOPIC], + operation_mode, self._qos, self._retain) + + if self._topic[CONF_MODE_STATE_TOPIC] is None: + self._current_operation = operation_mode + self.async_schedule_update_ha_state() + + @property + def current_swing_mode(self): + """Return the swing setting.""" + return self._current_swing_mode + + @property + def swing_list(self): + """List of available swing modes.""" + return self._swing_list + + @asyncio.coroutine + def async_turn_away_mode_on(self): + """Turn away mode on.""" + if self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, + self._topic[CONF_AWAY_MODE_COMMAND_TOPIC], + self._payload_on, self._qos, self._retain) + + if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None: + self._away = True + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_turn_away_mode_off(self): + """Turn away mode off.""" + if self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, + self._topic[CONF_AWAY_MODE_COMMAND_TOPIC], + self._payload_off, self._qos, self._retain) + + if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None: + self._away = False + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_set_hold_mode(self, hold): + """Update hold mode on.""" + if self._topic[CONF_HOLD_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, + self._topic[CONF_HOLD_COMMAND_TOPIC], + hold, self._qos, self._retain) + + if self._topic[CONF_HOLD_STATE_TOPIC] is None: + self._hold = hold + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_turn_aux_heat_on(self): + """Turn auxillary heater on.""" + if self._topic[CONF_AUX_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, self._topic[CONF_AUX_COMMAND_TOPIC], + self._payload_on, self._qos, self._retain) + + if self._topic[CONF_AUX_STATE_TOPIC] is None: + self._aux = True + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_turn_aux_heat_off(self): + """Turn auxillary heater off.""" + if self._topic[CONF_AUX_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, self._topic[CONF_AUX_COMMAND_TOPIC], + self._payload_off, self._qos, self._retain) + + if self._topic[CONF_AUX_STATE_TOPIC] is None: + self._aux = False + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 4aebb1c85c998a..ecc5667f9270b9 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -1,5 +1,5 @@ set_aux_heat: - description: Turn auxillary heater on/off for climate device + description: Turn auxiliary heater on/off for climate device fields: entity_id: @@ -101,11 +101,11 @@ set_swing_mode: fields: entity_id: description: Name(s) of entities to change - example: '.nest' + example: 'climate.nest' swing_mode: - description: New value of swing mode - example: 1 + description: New value of swing mode + example: 1 ecobee_set_fan_min_on_time: description: Set the minimum fan on time diff --git a/homeassistant/components/climate/tesla.py b/homeassistant/components/climate/tesla.py index 39d002e72d9b12..684d131d96011e 100644 --- a/homeassistant/components/climate/tesla.py +++ b/homeassistant/components/climate/tesla.py @@ -35,7 +35,6 @@ def __init__(self, tesla_device, controller): self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) self._target_temperature = None self._temperature = None - self._name = self.tesla_device.name @property def current_operation(self): diff --git a/homeassistant/components/climate/toon.py b/homeassistant/components/climate/toon.py new file mode 100644 index 00000000000000..c4021a97c91296 --- /dev/null +++ b/homeassistant/components/climate/toon.py @@ -0,0 +1,95 @@ +""" +Toon van Eneco Thermostat Support. + +This provides a component for the rebranded Quby thermostat as provided by +Eneco. +""" + +from homeassistant.components.climate import (ClimateDevice, + ATTR_TEMPERATURE, + STATE_PERFORMANCE, + STATE_HEAT, + STATE_ECO, + STATE_COOL) +from homeassistant.const import TEMP_CELSIUS + +import homeassistant.components.toon as toon_main + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup thermostat.""" + # Add toon + add_devices((ThermostatDevice(hass), ), True) + + +class ThermostatDevice(ClimateDevice): + """Interface class for the toon module and HA.""" + + def __init__(self, hass): + """Initialize the device.""" + self._name = 'Toon van Eneco' + self.hass = hass + self.thermos = hass.data[toon_main.TOON_HANDLE] + + # set up internal state vars + self._state = None + self._temperature = None + self._setpoint = None + self._operation_list = [STATE_PERFORMANCE, + STATE_HEAT, + STATE_ECO, + STATE_COOL] + + @property + def name(self): + """Name of this Thermostat.""" + return self._name + + @property + def should_poll(self): + """Polling is required.""" + return True + + @property + def temperature_unit(self): + """The unit of measurement used by the platform.""" + return TEMP_CELSIUS + + @property + def current_operation(self): + """Return current operation i.e. comfort, home, away.""" + state = self.thermos.get_data('state') + return state + + @property + def operation_list(self): + """List of available operation modes.""" + return self._operation_list + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.thermos.get_data('temp') + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.thermos.get_data('setpoint') + + def set_temperature(self, **kwargs): + """Change the setpoint of the thermostat.""" + temp = kwargs.get(ATTR_TEMPERATURE) + self.thermos.set_temp(temp) + + def set_operation_mode(self, operation_mode): + """Set new operation mode as toonlib requires it.""" + toonlib_values = {STATE_PERFORMANCE: 'Comfort', + STATE_HEAT: 'Home', + STATE_ECO: 'Away', + STATE_COOL: 'Sleep'} + + self.thermos.set_state(toonlib_values[operation_mode]) + + def update(self): + """Update local state.""" + self.thermos.update() diff --git a/homeassistant/components/climate/wink.py b/homeassistant/components/climate/wink.py index f52340dc62730e..f72cefc08416d0 100644 --- a/homeassistant/components/climate/wink.py +++ b/homeassistant/components/climate/wink.py @@ -1,30 +1,45 @@ """ -Support for Wink thermostats. +Support for Wink thermostats, Air Conditioners, and Water Heaters. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.wink/ """ +import logging import asyncio from homeassistant.components.wink import WinkDevice, DOMAIN from homeassistant.components.climate import ( STATE_AUTO, STATE_COOL, STATE_HEAT, ClimateDevice, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - ATTR_TEMPERATURE, - ATTR_CURRENT_HUMIDITY) + ATTR_TEMPERATURE, STATE_FAN_ONLY, + ATTR_CURRENT_HUMIDITY, STATE_ECO, STATE_ELECTRIC, + STATE_PERFORMANCE, STATE_HIGH_DEMAND, + STATE_HEAT_PUMP, STATE_GAS) from homeassistant.const import ( TEMP_CELSIUS, STATE_ON, STATE_OFF, STATE_UNKNOWN) +_LOGGER = logging.getLogger(__name__) + DEPENDENCIES = ['wink'] -STATE_AUX = 'aux' -STATE_ECO = 'eco' -STATE_FAN = 'fan' SPEED_LOW = 'low' SPEED_MEDIUM = 'medium' SPEED_HIGH = 'high' +HA_STATE_TO_WINK = {STATE_AUTO: 'auto', + STATE_ECO: 'eco', + STATE_FAN_ONLY: 'fan_only', + STATE_HEAT: 'heat_only', + STATE_COOL: 'cool_only', + STATE_PERFORMANCE: 'performance', + STATE_HIGH_DEMAND: 'high_demand', + STATE_HEAT_PUMP: 'heat_pump', + STATE_ELECTRIC: 'electric_only', + STATE_GAS: 'gas', + STATE_OFF: 'off'} +WINK_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_WINK.items()} + ATTR_EXTERNAL_TEMPERATURE = "external_temperature" ATTR_SMART_TEMPERATURE = "smart_temperature" ATTR_ECO_TARGET = "eco_target" @@ -32,28 +47,26 @@ def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Wink thermostat.""" + """Set up the Wink climate devices.""" import pywink - temp_unit = hass.config.units.temperature_unit for climate in pywink.get_thermostats(): _id = climate.object_id() + climate.name() if _id not in hass.data[DOMAIN]['unique_ids']: - add_devices([WinkThermostat(climate, hass, temp_unit)]) + add_devices([WinkThermostat(climate, hass)]) for climate in pywink.get_air_conditioners(): _id = climate.object_id() + climate.name() if _id not in hass.data[DOMAIN]['unique_ids']: - add_devices([WinkAC(climate, hass, temp_unit)]) + add_devices([WinkAC(climate, hass)]) + for water_heater in pywink.get_water_heaters(): + _id = water_heater.object_id() + water_heater.name() + if _id not in hass.data[DOMAIN]['unique_ids']: + add_devices([WinkWaterHeater(water_heater, hass)]) # pylint: disable=abstract-method class WinkThermostat(WinkDevice, ClimateDevice): """Representation of a Wink thermostat.""" - def __init__(self, wink, hass, temp_unit): - """Initialize the Wink device.""" - super().__init__(wink, hass) - self._config_temp_unit = temp_unit - @asyncio.coroutine def async_added_to_hass(self): """Callback when entity is added to hass.""" @@ -139,18 +152,12 @@ def current_operation(self): """Return current operation ie. heat, cool, idle.""" if not self.wink.is_on(): current_op = STATE_OFF - elif self.wink.current_hvac_mode() == 'cool_only': - current_op = STATE_COOL - elif self.wink.current_hvac_mode() == 'heat_only': - current_op = STATE_HEAT - elif self.wink.current_hvac_mode() == 'aux': - current_op = STATE_HEAT - elif self.wink.current_hvac_mode() == 'auto': - current_op = STATE_AUTO - elif self.wink.current_hvac_mode() == 'eco': - current_op = STATE_ECO else: - current_op = STATE_UNKNOWN + current_op = WINK_STATE_TO_HA.get(self.wink.current_hvac_mode()) + if current_op == 'aux': + return STATE_HEAT + if current_op is None: + current_op = STATE_UNKNOWN return current_op @property @@ -199,11 +206,12 @@ def is_away_mode_on(self): @property def is_aux_heat_on(self): """Return true if aux heater.""" - if self.wink.current_hvac_mode() == 'aux' and self.wink.is_on(): + if 'aux' not in self.wink.hvac_modes(): + return None + + if self.wink.current_hvac_mode() == 'aux': return True - elif self.wink.current_hvac_mode() == 'aux' and not self.wink.is_on(): - return False - return None + return False def set_temperature(self, **kwargs): """Set new target temperature.""" @@ -223,32 +231,27 @@ def set_temperature(self, **kwargs): def set_operation_mode(self, operation_mode): """Set operation mode.""" - if operation_mode == STATE_HEAT: - self.wink.set_operation_mode('heat_only') - elif operation_mode == STATE_COOL: - self.wink.set_operation_mode('cool_only') - elif operation_mode == STATE_AUTO: - self.wink.set_operation_mode('auto') - elif operation_mode == STATE_OFF: - self.wink.set_operation_mode('off') - elif operation_mode == STATE_AUX: - self.wink.set_operation_mode('aux') - elif operation_mode == STATE_ECO: - self.wink.set_operation_mode('eco') + op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode) + # The only way to disable aux heat is with the toggle + if self.is_aux_heat_on and op_mode_to_set == STATE_HEAT: + return + self.wink.set_operation_mode(op_mode_to_set) @property def operation_list(self): """List of available operation modes.""" op_list = ['off'] modes = self.wink.hvac_modes() - if 'cool_only' in modes: - op_list.append(STATE_COOL) - if 'heat_only' in modes or 'aux' in modes: - op_list.append(STATE_HEAT) - if 'auto' in modes: - op_list.append(STATE_AUTO) - if 'eco' in modes: - op_list.append(STATE_ECO) + for mode in modes: + if mode == 'aux': + continue + ha_mode = WINK_STATE_TO_HA.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + else: + error = "Invaid operation mode mapping. " + mode + \ + " doesn't map. Please report this." + _LOGGER.error(error) return op_list def turn_away_mode_on(self): @@ -281,12 +284,12 @@ def set_fan_mode(self, fan): self.wink.set_fan_mode(fan.lower()) def turn_aux_heat_on(self): - """Turn auxillary heater on.""" - self.set_operation_mode(STATE_AUX) + """Turn auxiliary heater on.""" + self.wink.set_operation_mode('aux') def turn_aux_heat_off(self): - """Turn auxillary heater off.""" - self.set_operation_mode(STATE_AUTO) + """Turn auxiliary heater off.""" + self.set_operation_mode(STATE_HEAT) @property def min_temp(self): @@ -344,11 +347,6 @@ def max_temp(self): class WinkAC(WinkDevice, ClimateDevice): """Representation of a Wink air conditioner.""" - def __init__(self, wink, hass, temp_unit): - """Initialize the Wink device.""" - super().__init__(wink, hass) - self._config_temp_unit = temp_unit - @property def temperature_unit(self): """Return the unit of measurement.""" @@ -382,14 +380,10 @@ def current_operation(self): """Return current operation ie. heat, cool, idle.""" if not self.wink.is_on(): current_op = STATE_OFF - elif self.wink.current_mode() == 'cool_only': - current_op = STATE_COOL - elif self.wink.current_mode() == 'auto_eco': - current_op = STATE_ECO - elif self.wink.current_mode() == 'fan_only': - current_op = STATE_FAN else: - current_op = STATE_UNKNOWN + current_op = WINK_STATE_TO_HA.get(self.wink.current_hvac_mode()) + if current_op is None: + current_op = STATE_UNKNOWN return current_op @property @@ -397,12 +391,14 @@ def operation_list(self): """List of available operation modes.""" op_list = ['off'] modes = self.wink.modes() - if 'cool_only' in modes: - op_list.append(STATE_COOL) - if 'auto_eco' in modes: - op_list.append(STATE_ECO) - if 'fan_only' in modes: - op_list.append(STATE_FAN) + for mode in modes: + ha_mode = WINK_STATE_TO_HA.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + else: + error = "Invaid operation mode mapping. " + mode + \ + " doesn't map. Please report this." + _LOGGER.error(error) return op_list def set_temperature(self, **kwargs): @@ -412,30 +408,16 @@ def set_temperature(self, **kwargs): def set_operation_mode(self, operation_mode): """Set operation mode.""" - if operation_mode == STATE_COOL: - self.wink.set_operation_mode('cool_only') - elif operation_mode == STATE_ECO: - self.wink.set_operation_mode('auto_eco') - elif operation_mode == STATE_OFF: - self.wink.set_operation_mode('off') - elif operation_mode == STATE_FAN: - self.wink.set_operation_mode('fan_only') + op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode) + if op_mode_to_set == 'eco': + op_mode_to_set = 'auto_eco' + self.wink.set_operation_mode(op_mode_to_set) @property def target_temperature(self): """Return the temperature we try to reach.""" return self.wink.current_max_set_point() - @property - def target_temperature_low(self): - """Only supports cool.""" - return None - - @property - def target_temperature_high(self): - """Only supports cool.""" - return None - @property def current_fan_mode(self): """Return the current fan mode.""" @@ -453,12 +435,97 @@ def fan_list(self): """Return a list of available fan modes.""" return [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - def set_fan_mode(self, mode): + def set_fan_mode(self, fan): """Set fan speed.""" - if mode == SPEED_LOW: + if fan == SPEED_LOW: speed = 0.4 - elif mode == SPEED_MEDIUM: + elif fan == SPEED_MEDIUM: speed = 0.8 - elif mode == SPEED_HIGH: + elif fan == SPEED_HIGH: speed = 1.0 self.wink.set_ac_fan_speed(speed) + + +class WinkWaterHeater(WinkDevice, ClimateDevice): + """Representation of a Wink water heater.""" + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + # The Wink API always returns temp in Celsius + return TEMP_CELSIUS + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + data = {} + data["vacation_mode"] = self.wink.vacation_mode_enabled() + data["rheem_type"] = self.wink.rheem_type() + + return data + + @property + def current_operation(self): + """ + Return current operation one of the following. + + ["eco", "performance", "heat_pump", + "high_demand", "electric_only", "gas] + """ + if not self.wink.is_on(): + current_op = STATE_OFF + else: + current_op = WINK_STATE_TO_HA.get(self.wink.current_mode()) + if current_op is None: + current_op = STATE_UNKNOWN + return current_op + + @property + def operation_list(self): + """List of available operation modes.""" + op_list = ['off'] + modes = self.wink.modes() + for mode in modes: + if mode == 'aux': + continue + ha_mode = WINK_STATE_TO_HA.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + else: + error = "Invaid operation mode mapping. " + mode + \ + " doesn't map. Please report this." + _LOGGER.error(error) + return op_list + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + target_temp = kwargs.get(ATTR_TEMPERATURE) + self.wink.set_temperature(target_temp) + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode) + self.wink.set_operation_mode(op_mode_to_set) + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.wink.current_set_point() + + def turn_away_mode_on(self): + """Turn away on.""" + self.wink.set_vacation_mode(True) + + def turn_away_mode_off(self): + """Turn away off.""" + self.wink.set_vacation_mode(False) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self.wink.min_set_point() + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self.wink.max_set_point() diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 44796f97166c17..c711b00fdd2135 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -1,47 +1,147 @@ """Component to integrate the Home Assistant cloud.""" import asyncio +import json import logging +import os import voluptuous as vol -from . import http_api, auth_api -from .const import DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_START +from . import http_api, iot +from .const import CONFIG_DIR, DOMAIN, SERVERS -REQUIREMENTS = ['warrant==0.2.0'] + +REQUIREMENTS = ['warrant==0.5.0'] DEPENDENCIES = ['http'] CONF_MODE = 'mode' +CONF_COGNITO_CLIENT_ID = 'cognito_client_id' +CONF_USER_POOL_ID = 'user_pool_id' +CONF_REGION = 'region' +CONF_RELAYER = 'relayer' MODE_DEV = 'development' -MODE_STAGING = 'staging' -MODE_PRODUCTION = 'production' DEFAULT_MODE = MODE_DEV +_LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_MODE, default=DEFAULT_MODE): - vol.In([MODE_DEV, MODE_STAGING, MODE_PRODUCTION]), + vol.In([MODE_DEV] + list(SERVERS)), + # Change to optional when we include real servers + vol.Required(CONF_COGNITO_CLIENT_ID): str, + vol.Required(CONF_USER_POOL_ID): str, + vol.Required(CONF_REGION): str, + vol.Required(CONF_RELAYER): str, }), }, extra=vol.ALLOW_EXTRA) -_LOGGER = logging.getLogger(__name__) @asyncio.coroutine def async_setup(hass, config): """Initialize the Home Assistant cloud.""" - mode = MODE_PRODUCTION - if DOMAIN in config: - mode = config[DOMAIN].get(CONF_MODE) + kwargs = config[DOMAIN] + else: + kwargs = {CONF_MODE: DEFAULT_MODE} - if mode != 'development': - _LOGGER.error('Only development mode is currently allowed.') - return False + cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs) - data = hass.data[DOMAIN] = { - 'mode': mode - } + @asyncio.coroutine + def init_cloud(event): + """Initialize connection.""" + yield from cloud.initialize() - data['auth'] = yield from hass.async_add_job(auth_api.load_auth, hass) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, init_cloud) yield from http_api.async_setup(hass) return True + + +class Cloud: + """Store the configuration of the cloud connection.""" + + def __init__(self, hass, mode, cognito_client_id=None, user_pool_id=None, + region=None, relayer=None): + """Create an instance of Cloud.""" + self.hass = hass + self.mode = mode + self.email = None + self.id_token = None + self.access_token = None + self.refresh_token = None + self.iot = iot.CloudIoT(self) + + if mode == MODE_DEV: + self.cognito_client_id = cognito_client_id + self.user_pool_id = user_pool_id + self.region = region + self.relayer = relayer + + else: + info = SERVERS[mode] + + self.cognito_client_id = info['cognito_client_id'] + self.user_pool_id = info['user_pool_id'] + self.region = info['region'] + self.relayer = info['relayer'] + + @property + def is_logged_in(self): + """Get if cloud is logged in.""" + return self.email is not None + + @property + def user_info_path(self): + """Get path to the stored auth.""" + return self.path('{}_auth.json'.format(self.mode)) + + @asyncio.coroutine + def initialize(self): + """Initialize and load cloud info.""" + def load_config(): + """Load the configuration.""" + # Ensure config dir exists + path = self.hass.config.path(CONFIG_DIR) + if not os.path.isdir(path): + os.mkdir(path) + + user_info = self.user_info_path + if os.path.isfile(user_info): + with open(user_info, 'rt') as file: + info = json.loads(file.read()) + self.email = info['email'] + self.id_token = info['id_token'] + self.access_token = info['access_token'] + self.refresh_token = info['refresh_token'] + + yield from self.hass.async_add_job(load_config) + + if self.email is not None: + yield from self.iot.connect() + + def path(self, *parts): + """Get config path inside cloud dir.""" + return self.hass.config.path(CONFIG_DIR, *parts) + + @asyncio.coroutine + def logout(self): + """Close connection and remove all credentials.""" + yield from self.iot.disconnect() + + self.email = None + self.id_token = None + self.access_token = None + self.refresh_token = None + + yield from self.hass.async_add_job( + lambda: os.remove(self.user_info_path)) + + def write_user_info(self): + """Write user info to a file.""" + with open(self.user_info_path, 'wt') as file: + file.write(json.dumps({ + 'email': self.email, + 'id_token': self.id_token, + 'access_token': self.access_token, + 'refresh_token': self.refresh_token, + }, indent=4)) diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index 0baadeece46763..50a88d4be4dac1 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -1,10 +1,7 @@ -"""Package to offer tools to authenticate with the cloud.""" -import json +"""Package to communicate with the authentication API.""" +import hashlib import logging -import os -from .const import AUTH_FILE, SERVERS -from .util import get_mode _LOGGER = logging.getLogger(__name__) @@ -61,210 +58,120 @@ def _map_aws_exception(err): return ex(err.response['Error']['Message']) -def load_auth(hass): - """Load authentication from disk and verify it.""" - info = _read_info(hass) +def _generate_username(email): + """Generate a username from an email address.""" + return hashlib.sha512(email.encode('utf-8')).hexdigest() - if info is None: - return Auth(hass) - auth = Auth(hass, _cognito( - hass, - id_token=info['id_token'], - access_token=info['access_token'], - refresh_token=info['refresh_token'], - )) - - if auth.validate_auth(): - return auth - - return Auth(hass) - - -def register(hass, email, password): +def register(cloud, email, password): """Register a new account.""" from botocore.exceptions import ClientError - cognito = _cognito(hass, username=email) + cognito = _cognito(cloud) try: - cognito.register(email, password) + cognito.register(_generate_username(email), password, email=email) except ClientError as err: raise _map_aws_exception(err) -def confirm_register(hass, confirmation_code, email): +def confirm_register(cloud, confirmation_code, email): """Confirm confirmation code after registration.""" from botocore.exceptions import ClientError - cognito = _cognito(hass, username=email) + cognito = _cognito(cloud) try: - cognito.confirm_sign_up(confirmation_code, email) + cognito.confirm_sign_up(confirmation_code, _generate_username(email)) except ClientError as err: raise _map_aws_exception(err) -def forgot_password(hass, email): +def forgot_password(cloud, email): """Initiate forgotten password flow.""" from botocore.exceptions import ClientError - cognito = _cognito(hass, username=email) + cognito = _cognito(cloud, username=_generate_username(email)) try: cognito.initiate_forgot_password() except ClientError as err: raise _map_aws_exception(err) -def confirm_forgot_password(hass, confirmation_code, email, new_password): +def confirm_forgot_password(cloud, confirmation_code, email, new_password): """Confirm forgotten password code and change password.""" from botocore.exceptions import ClientError - cognito = _cognito(hass, username=email) + cognito = _cognito(cloud, username=_generate_username(email)) try: cognito.confirm_forgot_password(confirmation_code, new_password) except ClientError as err: raise _map_aws_exception(err) -class Auth(object): - """Class that holds Cloud authentication.""" - - def __init__(self, hass, cognito=None): - """Initialize Hass cloud info object.""" - self.hass = hass - self.cognito = cognito - self.account = None - - @property - def is_logged_in(self): - """Return if user is logged in.""" - return self.account is not None - - def validate_auth(self): - """Validate that the contained auth is valid.""" - from botocore.exceptions import ClientError - - try: - self._refresh_account_info() - except ClientError as err: - if err.response['Error']['Code'] != 'NotAuthorizedException': - _LOGGER.error('Unexpected error verifying auth: %s', err) - return False - - try: - self.renew_access_token() - self._refresh_account_info() - except ClientError: - _LOGGER.error('Unable to refresh auth token: %s', err) - return False - - return True - - def login(self, username, password): - """Login using a username and password.""" - from botocore.exceptions import ClientError - from warrant.exceptions import ForceChangePasswordException - - cognito = _cognito(self.hass, username=username) - - try: - cognito.authenticate(password=password) - self.cognito = cognito - self._refresh_account_info() - _write_info(self.hass, self) - - except ForceChangePasswordException as err: - raise PasswordChangeRequired - - except ClientError as err: - raise _map_aws_exception(err) - - def _refresh_account_info(self): - """Refresh the account info. - - Raises boto3 exceptions. - """ - self.account = self.cognito.get_user() +def login(cloud, email, password): + """Log user in and fetch certificate.""" + cognito = _authenticate(cloud, email, password) + cloud.id_token = cognito.id_token + cloud.access_token = cognito.access_token + cloud.refresh_token = cognito.refresh_token + cloud.email = email + cloud.write_user_info() - def renew_access_token(self): - """Refresh token.""" - from botocore.exceptions import ClientError - try: - self.cognito.renew_access_token() - _write_info(self.hass, self) - return True - except ClientError as err: - _LOGGER.error('Error refreshing token: %s', err) - return False - - def logout(self): - """Invalidate token.""" - from botocore.exceptions import ClientError - - try: - self.cognito.logout() - self.account = None - _write_info(self.hass, self) - except ClientError as err: - raise _map_aws_exception(err) +def check_token(cloud): + """Check that the token is valid and verify if needed.""" + from botocore.exceptions import ClientError + cognito = _cognito( + cloud, + access_token=cloud.access_token, + refresh_token=cloud.refresh_token) -def _read_info(hass): - """Read auth file.""" - path = hass.config.path(AUTH_FILE) + try: + if cognito.check_token(): + cloud.id_token = cognito.id_token + cloud.access_token = cognito.access_token + cloud.write_user_info() + except ClientError as err: + raise _map_aws_exception(err) - if not os.path.isfile(path): - return None - with open(path) as file: - return json.load(file).get(get_mode(hass)) +def _authenticate(cloud, email, password): + """Log in and return an authenticated Cognito instance.""" + from botocore.exceptions import ClientError + from warrant.exceptions import ForceChangePasswordException + assert not cloud.is_logged_in, 'Cannot login if already logged in.' -def _write_info(hass, auth): - """Write auth info for specified mode. + cognito = _cognito(cloud, username=email) - Pass in None for data to remove authentication for that mode. - """ - path = hass.config.path(AUTH_FILE) - mode = get_mode(hass) - - if os.path.isfile(path): - with open(path) as file: - content = json.load(file) - else: - content = {} + try: + cognito.authenticate(password=password) + return cognito - if auth.is_logged_in: - content[mode] = { - 'id_token': auth.cognito.id_token, - 'access_token': auth.cognito.access_token, - 'refresh_token': auth.cognito.refresh_token, - } - else: - content.pop(mode, None) + except ForceChangePasswordException as err: + raise PasswordChangeRequired - with open(path, 'wt') as file: - file.write(json.dumps(content, indent=4, sort_keys=True)) + except ClientError as err: + raise _map_aws_exception(err) -def _cognito(hass, **kwargs): +def _cognito(cloud, **kwargs): """Get the client credentials.""" + import botocore + import boto3 from warrant import Cognito - mode = get_mode(hass) - - info = SERVERS.get(mode) - - if info is None: - raise ValueError('Mode {} is not supported.'.format(mode)) - cognito = Cognito( - user_pool_id=info['identity_pool_id'], - client_id=info['client_id'], - user_pool_region=info['region'], - access_key=info['access_key_id'], - secret_key=info['secret_access_key'], + user_pool_id=cloud.user_pool_id, + client_id=cloud.cognito_client_id, + user_pool_region=cloud.region, **kwargs ) - + cognito.client = boto3.client( + 'cognito-idp', + region_name=cloud.region, + config=botocore.config.Config( + signature_version=botocore.UNSIGNED + ) + ) return cognito diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 81beab1891b7c1..334e522f81b76e 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -1,14 +1,14 @@ """Constants for the cloud component.""" DOMAIN = 'cloud' +CONFIG_DIR = '.cloud' REQUEST_TIMEOUT = 10 -AUTH_FILE = '.cloud' SERVERS = { - 'development': { - 'client_id': '3k755iqfcgv8t12o4pl662mnos', - 'identity_pool_id': 'us-west-2_vDOfweDJo', - 'region': 'us-west-2', - 'access_key_id': 'AKIAJGRK7MILPRJTT2ZQ', - 'secret_access_key': 'lscdYBApxrLWL0HKuVqVXWv3ou8ZVXgG7rZBu/Sz' - } + # Example entry: + # 'production': { + # 'cognito_client_id': '', + # 'user_pool_id': '', + # 'region': '', + # 'relayer': '' + # } } diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 941df7648a6545..aa91f5a45e7e65 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -10,7 +10,7 @@ HomeAssistantView, RequestDataValidator) from . import auth_api -from .const import REQUEST_TIMEOUT +from .const import DOMAIN, REQUEST_TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -74,13 +74,14 @@ class CloudLoginView(HomeAssistantView): def post(self, request, data): """Handle login request.""" hass = request.app['hass'] - auth = hass.data['cloud']['auth'] + cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from hass.async_add_job(auth.login, data['email'], + yield from hass.async_add_job(auth_api.login, cloud, data['email'], data['password']) + hass.async_add_job(cloud.iot.connect) - return self.json(_auth_data(auth)) + return self.json(_account_data(cloud)) class CloudLogoutView(HomeAssistantView): @@ -94,10 +95,10 @@ class CloudLogoutView(HomeAssistantView): def post(self, request): """Handle logout request.""" hass = request.app['hass'] - auth = hass.data['cloud']['auth'] + cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from hass.async_add_job(auth.logout) + yield from cloud.logout() return self.json_message('ok') @@ -112,12 +113,12 @@ class CloudAccountView(HomeAssistantView): def get(self, request): """Get account info.""" hass = request.app['hass'] - auth = hass.data['cloud']['auth'] + cloud = hass.data[DOMAIN] - if not auth.is_logged_in: + if not cloud.is_logged_in: return self.json_message('Not logged in', 400) - return self.json(_auth_data(auth)) + return self.json(_account_data(cloud)) class CloudRegisterView(HomeAssistantView): @@ -135,10 +136,11 @@ class CloudRegisterView(HomeAssistantView): def post(self, request, data): """Handle registration request.""" hass = request.app['hass'] + cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): yield from hass.async_add_job( - auth_api.register, hass, data['email'], data['password']) + auth_api.register, cloud, data['email'], data['password']) return self.json_message('ok') @@ -158,10 +160,11 @@ class CloudConfirmRegisterView(HomeAssistantView): def post(self, request, data): """Handle registration confirmation request.""" hass = request.app['hass'] + cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): yield from hass.async_add_job( - auth_api.confirm_register, hass, data['confirmation_code'], + auth_api.confirm_register, cloud, data['confirmation_code'], data['email']) return self.json_message('ok') @@ -181,10 +184,11 @@ class CloudForgotPasswordView(HomeAssistantView): def post(self, request, data): """Handle forgot password request.""" hass = request.app['hass'] + cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): yield from hass.async_add_job( - auth_api.forgot_password, hass, data['email']) + auth_api.forgot_password, cloud, data['email']) return self.json_message('ok') @@ -205,18 +209,19 @@ class CloudConfirmForgotPasswordView(HomeAssistantView): def post(self, request, data): """Handle forgot password confirm request.""" hass = request.app['hass'] + cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): yield from hass.async_add_job( - auth_api.confirm_forgot_password, hass, + auth_api.confirm_forgot_password, cloud, data['confirmation_code'], data['email'], data['new_password']) return self.json_message('ok') -def _auth_data(auth): +def _account_data(cloud): """Generate the auth data JSON response.""" return { - 'email': auth.account.email + 'email': cloud.email } diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py new file mode 100644 index 00000000000000..92b517b570ccaf --- /dev/null +++ b/homeassistant/components/cloud/iot.py @@ -0,0 +1,194 @@ +"""Module to handle messages from Home Assistant cloud.""" +import asyncio +import logging + +from aiohttp import hdrs, client_exceptions, WSMsgType + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.components.alexa import smart_home +from homeassistant.util.decorator import Registry +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from . import auth_api + + +HANDLERS = Registry() +_LOGGER = logging.getLogger(__name__) + + +class UnknownHandler(Exception): + """Exception raised when trying to handle unknown handler.""" + + +class CloudIoT: + """Class to manage the IoT connection.""" + + def __init__(self, cloud): + """Initialize the CloudIoT class.""" + self.cloud = cloud + self.client = None + self.close_requested = False + self.tries = 0 + + @property + def is_connected(self): + """Return if connected to the cloud.""" + return self.client is not None + + @asyncio.coroutine + def connect(self): + """Connect to the IoT broker.""" + if self.client is not None: + raise RuntimeError('Cannot connect while already connected') + + self.close_requested = False + + hass = self.cloud.hass + remove_hass_stop_listener = None + + session = async_get_clientsession(self.cloud.hass) + + @asyncio.coroutine + def _handle_hass_stop(event): + """Handle Home Assistant shutting down.""" + nonlocal remove_hass_stop_listener + remove_hass_stop_listener = None + yield from self.disconnect() + + client = None + disconnect_warn = None + try: + yield from hass.async_add_job(auth_api.check_token, self.cloud) + + self.client = client = yield from session.ws_connect( + self.cloud.relayer, headers={ + hdrs.AUTHORIZATION: + 'Bearer {}'.format(self.cloud.access_token) + }) + self.tries = 0 + + remove_hass_stop_listener = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, _handle_hass_stop) + + _LOGGER.info('Connected') + + while not client.closed: + msg = yield from client.receive() + + if msg.type in (WSMsgType.ERROR, WSMsgType.CLOSED, + WSMsgType.CLOSING): + disconnect_warn = 'Closed by server' + break + + elif msg.type != WSMsgType.TEXT: + disconnect_warn = 'Received non-Text message: {}'.format( + msg.type) + break + + try: + msg = msg.json() + except ValueError: + disconnect_warn = 'Received invalid JSON.' + break + + _LOGGER.debug('Received message: %s', msg) + + response = { + 'msgid': msg['msgid'], + } + try: + result = yield from async_handle_message( + hass, self.cloud, msg['handler'], msg['payload']) + + # No response from handler + if result is None: + continue + + response['payload'] = result + + except UnknownHandler: + response['error'] = 'unknown-handler' + + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error handling message') + response['error'] = 'exception' + + _LOGGER.debug('Publishing message: %s', response) + yield from client.send_json(response) + + except auth_api.CloudError: + _LOGGER.warning('Unable to connect: Unable to refresh token.') + + except client_exceptions.WSServerHandshakeError as err: + if err.code == 401: + disconnect_warn = 'Invalid auth.' + self.close_requested = True + # Should we notify user? + else: + _LOGGER.warning('Unable to connect: %s', err) + + except client_exceptions.ClientError as err: + _LOGGER.warning('Unable to connect: %s', err) + + except Exception: # pylint: disable=broad-except + if not self.close_requested: + _LOGGER.exception('Unexpected error') + + finally: + if disconnect_warn is not None: + _LOGGER.warning('Connection closed: %s', disconnect_warn) + + if remove_hass_stop_listener is not None: + remove_hass_stop_listener() + + if client is not None: + self.client = None + yield from client.close() + + if not self.close_requested: + self.tries += 1 + + # Sleep 0, 5, 10, 15 … up to 30 seconds between retries + yield from asyncio.sleep( + min(30, (self.tries - 1) * 5), loop=hass.loop) + + hass.async_add_job(self.connect()) + + @asyncio.coroutine + def disconnect(self): + """Disconnect the client.""" + self.close_requested = True + yield from self.client.close() + + +@asyncio.coroutine +def async_handle_message(hass, cloud, handler_name, payload): + """Handle incoming IoT message.""" + handler = HANDLERS.get(handler_name) + + if handler is None: + raise UnknownHandler() + + return (yield from handler(hass, cloud, payload)) + + +@HANDLERS.register('alexa') +@asyncio.coroutine +def async_handle_alexa(hass, cloud, payload): + """Handle an incoming IoT message for Alexa.""" + return (yield from smart_home.async_handle_message(hass, payload)) + + +@HANDLERS.register('cloud') +@asyncio.coroutine +def async_handle_cloud(hass, cloud, payload): + """Handle an incoming IoT message for cloud component.""" + action = payload['action'] + + if action == 'logout': + yield from cloud.logout() + _LOGGER.error('You have been logged out from Home Assistant cloud: %s', + payload['reason']) + else: + _LOGGER.warning('Received unknown cloud action: %s', action) + + return None diff --git a/homeassistant/components/cloud/util.py b/homeassistant/components/cloud/util.py deleted file mode 100644 index ec5445f0638c06..00000000000000 --- a/homeassistant/components/cloud/util.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Utilities for the cloud integration.""" -from .const import DOMAIN - - -def get_mode(hass): - """Return the current mode of the cloud component. - - Async friendly. - """ - return hass.data[DOMAIN]['mode'] diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 23c0be1a43e1ba..ba60382ae64eda 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -169,21 +169,12 @@ def async_handle_cover_service(service): params.pop(ATTR_ENTITY_ID, None) # call method - for cover in covers: - yield from getattr(cover, method['method'])(**params) - update_tasks = [] - for cover in covers: + yield from getattr(cover, method['method'])(**params) if not cover.should_poll: continue - - update_coro = hass.async_add_job( - cover.async_update_ha_state(True)) - if hasattr(cover, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(cover.async_update_ha_state(True)) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) diff --git a/homeassistant/components/cover/abode.py b/homeassistant/components/cover/abode.py index b09c9e5e007620..6eb0369aa3f202 100644 --- a/homeassistant/components/cover/abode.py +++ b/homeassistant/components/cover/abode.py @@ -6,7 +6,7 @@ """ import logging -from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN from homeassistant.components.cover import CoverDevice @@ -19,31 +19,32 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Abode cover devices.""" import abodepy.helpers.constants as CONST - abode = hass.data[DATA_ABODE] + data = hass.data[ABODE_DOMAIN] - sensors = [] - for sensor in abode.get_devices(type_filter=(CONST.DEVICE_SECURE_BARRIER)): - sensors.append(AbodeCover(abode, sensor)) + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_COVER): + if data.is_excluded(device): + continue - add_devices(sensors) + devices.append(AbodeCover(data, device)) + + data.devices.extend(devices) + + add_devices(devices) class AbodeCover(AbodeDevice, CoverDevice): """Representation of an Abode cover.""" - def __init__(self, controller, device): - """Initialize the Abode device.""" - AbodeDevice.__init__(self, controller, device) - @property def is_closed(self): """Return true if cover is closed, else False.""" - return self._device.is_open is False + return not self._device.is_open - def close_cover(self): + def close_cover(self, **kwargs): """Issue close command to cover.""" self._device.close_cover() - def open_cover(self): + def open_cover(self, **kwargs): """Issue open command to cover.""" self._device.open_cover() diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index 296d8d36394f9a..b840c780645b68 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -50,7 +50,7 @@ @asyncio.coroutine -def async_setup_platform(hass, config, add_devices, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up cover(s) for KNX platform.""" if DATA_KNX not in hass.data \ @@ -58,25 +58,25 @@ def async_setup_platform(hass, config, add_devices, return False if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) + async_add_devices_config(hass, config, async_add_devices) return True @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up covers for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXCover(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): +def async_add_devices_config(hass, config, async_add_devices): """Set up cover for KNX platform configured within plattform.""" import xknx cover = xknx.devices.Cover( @@ -90,23 +90,20 @@ def async_add_devices_config(hass, config, add_devices): group_address_angle_state=config.get(CONF_ANGLE_STATE_ADDRESS), group_address_position=config.get(CONF_POSITION_ADDRESS), travel_time_down=config.get(CONF_TRAVELLING_TIME_DOWN), - travel_time_up=config.get(CONF_TRAVELLING_TIME_UP)) + travel_time_up=config.get(CONF_TRAVELLING_TIME_UP), + invert_position=config.get(CONF_INVERT_POSITION), + invert_angle=config.get(CONF_INVERT_ANGLE)) - invert_position = config.get(CONF_INVERT_POSITION) - invert_angle = config.get(CONF_INVERT_ANGLE) hass.data[DATA_KNX].xknx.devices.add(cover) - add_devices([KNXCover(hass, cover, invert_position, invert_angle)]) + async_add_devices([KNXCover(hass, cover)]) class KNXCover(CoverDevice): """Representation of a KNX cover.""" - def __init__(self, hass, device, invert_position=False, - invert_angle=False): + def __init__(self, hass, device): """Initialize the cover.""" self.device = device - self.invert_position = invert_position - self.invert_angle = invert_angle self.hass = hass self.async_register_callbacks() @@ -144,9 +141,7 @@ def supported_features(self): @property def current_cover_position(self): """Return the current position of the cover.""" - return int(self.from_knx_position( - self.device.current_position(), - self.invert_position)) + return self.device.current_position() @property def is_closed(self): @@ -172,8 +167,7 @@ def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] - knx_position = self.to_knx_position(position, self.invert_position) - yield from self.device.set_position(knx_position) + yield from self.device.set_position(position) self.start_auto_updater() @asyncio.coroutine @@ -187,17 +181,14 @@ def current_cover_tilt_position(self): """Return current tilt position of cover.""" if not self.device.supports_angle: return None - return int(self.from_knx_position( - self.device.angle, - self.invert_angle)) + return self.device.current_angle() @asyncio.coroutine def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" if ATTR_TILT_POSITION in kwargs: - position = kwargs[ATTR_TILT_POSITION] - knx_position = self.to_knx_position(position, self.invert_angle) - yield from self.device.set_angle(knx_position) + tilt_position = kwargs[ATTR_TILT_POSITION] + yield from self.device.set_angle(tilt_position) def start_auto_updater(self): """Start the autoupdater to update HASS while cover is moving.""" @@ -220,20 +211,3 @@ def auto_updater_hook(self, now): self.stop_auto_updater() self.hass.add_job(self.device.auto_stop_if_necessary()) - - @staticmethod - def from_knx_position(raw, invert): - """Convert KNX position [0...255] to hass position [100...0].""" - position = round((raw/256)*100) - if not invert: - position = 100 - position - return position - - @staticmethod - def to_knx_position(value, invert): - """Convert hass position [100...0] to KNX position [0...255].""" - knx_position = round(value/100*255.4) - if not invert: - knx_position = 255-knx_position - print(value, " -> ", knx_position) - return knx_position diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index 8e197cc2e0251a..d10166a9469fab 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -21,8 +21,8 @@ CONF_NAME, CONF_VALUE_TEMPLATE, CONF_OPTIMISTIC, STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN) from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, - valid_publish_topic, valid_subscribe_topic) + CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_AVAILABILITY_TOPIC, + CONF_QOS, CONF_RETAIN, valid_publish_topic, valid_subscribe_topic) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -37,6 +37,8 @@ CONF_PAYLOAD_OPEN = 'payload_open' CONF_PAYLOAD_CLOSE = 'payload_close' CONF_PAYLOAD_STOP = 'payload_stop' +CONF_PAYLOAD_AVAILABLE = 'payload_available' +CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' CONF_STATE_OPEN = 'state_open' CONF_STATE_CLOSED = 'state_closed' CONF_TILT_CLOSED_POSITION = 'tilt_closed_value' @@ -50,6 +52,8 @@ DEFAULT_PAYLOAD_OPEN = 'OPEN' DEFAULT_PAYLOAD_CLOSE = 'CLOSE' DEFAULT_PAYLOAD_STOP = 'STOP' +DEFAULT_PAYLOAD_AVAILABLE = 'online' +DEFAULT_PAYLOAD_NOT_AVAILABLE = 'offline' DEFAULT_OPTIMISTIC = False DEFAULT_RETAIN = False DEFAULT_TILT_CLOSED_POSITION = 0 @@ -69,11 +73,16 @@ vol.Optional(CONF_SET_POSITION_TEMPLATE, default=None): cv.template, vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_AVAILABILITY_TOPIC, default=None): valid_subscribe_topic, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PAYLOAD_OPEN, default=DEFAULT_PAYLOAD_OPEN): cv.string, vol.Optional(CONF_PAYLOAD_CLOSE, default=DEFAULT_PAYLOAD_CLOSE): cv.string, vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string, + vol.Optional(CONF_PAYLOAD_AVAILABLE, + default=DEFAULT_PAYLOAD_AVAILABLE): cv.string, + vol.Optional(CONF_PAYLOAD_NOT_AVAILABLE, + default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string, vol.Optional(CONF_STATE_OPEN, default=STATE_OPEN): cv.string, vol.Optional(CONF_STATE_CLOSED, default=STATE_CLOSED): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, @@ -106,6 +115,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), config.get(CONF_COMMAND_TOPIC), + config.get(CONF_AVAILABILITY_TOPIC), config.get(CONF_TILT_COMMAND_TOPIC), config.get(CONF_TILT_STATUS_TOPIC), config.get(CONF_QOS), @@ -115,6 +125,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_PAYLOAD_OPEN), config.get(CONF_PAYLOAD_CLOSE), config.get(CONF_PAYLOAD_STOP), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), config.get(CONF_OPTIMISTIC), value_template, config.get(CONF_TILT_OPEN_POSITION), @@ -131,9 +143,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class MqttCover(CoverDevice): """Representation of a cover that can be controlled using MQTT.""" - def __init__(self, name, state_topic, command_topic, tilt_command_topic, - tilt_status_topic, qos, retain, state_open, state_closed, - payload_open, payload_close, payload_stop, + def __init__(self, name, state_topic, command_topic, availability_topic, + tilt_command_topic, tilt_status_topic, qos, retain, + state_open, state_closed, payload_open, payload_close, + payload_stop, payload_available, payload_not_available, optimistic, value_template, tilt_open_position, tilt_closed_position, tilt_min, tilt_max, tilt_optimistic, tilt_invert, position_topic, set_position_template): @@ -143,12 +156,16 @@ def __init__(self, name, state_topic, command_topic, tilt_command_topic, self._name = name self._state_topic = state_topic self._command_topic = command_topic + self._availability_topic = availability_topic + self._available = True if availability_topic is None else False self._tilt_command_topic = tilt_command_topic self._tilt_status_topic = tilt_status_topic self._qos = qos self._payload_open = payload_open self._payload_close = payload_close self._payload_stop = payload_stop + self._payload_available = payload_available + self._payload_not_available = payload_not_available self._state_open = state_open self._state_closed = state_closed self._retain = retain @@ -181,8 +198,8 @@ def tilt_updated(topic, payload, qos): self.async_schedule_update_ha_state() @callback - def message_received(topic, payload, qos): - """Handle new MQTT message.""" + def state_message_received(topic, payload, qos): + """Handle new MQTT state messages.""" if self._template is not None: payload = self._template.async_render_with_possible_json_value( payload) @@ -205,12 +222,28 @@ def message_received(topic, payload, qos): self.async_schedule_update_ha_state() + @callback + def availability_message_received(topic, payload, qos): + """Handle new MQTT availability messages.""" + if payload == self._payload_available: + self._available = True + elif payload == self._payload_not_available: + self._available = False + + self.async_schedule_update_ha_state() + if self._state_topic is None: # Force into optimistic mode. self._optimistic = True else: yield from mqtt.async_subscribe( - self.hass, self._state_topic, message_received, self._qos) + self.hass, self._state_topic, + state_message_received, self._qos) + + if self._availability_topic is not None: + yield from mqtt.async_subscribe( + self.hass, self._availability_topic, + availability_message_received, self._qos) if self._tilt_status_topic is None: self._tilt_optimistic = True @@ -230,6 +263,11 @@ def name(self): """Return the name of the cover.""" return self._name + @property + def available(self) -> bool: + """Return if cover is available.""" + return self._available + @property def is_closed(self): """Return if the cover is closed.""" diff --git a/homeassistant/components/cover/rflink.py b/homeassistant/components/cover/rflink.py new file mode 100644 index 00000000000000..a9b7598159f967 --- /dev/null +++ b/homeassistant/components/cover/rflink.py @@ -0,0 +1,121 @@ +""" +Support for Rflink Cover devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.rflink/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.components.rflink import ( + DATA_ENTITY_GROUP_LOOKUP, DATA_ENTITY_LOOKUP, + DEVICE_DEFAULTS_SCHEMA, EVENT_KEY_COMMAND, RflinkCommand) +from homeassistant.components.cover import ( + CoverDevice, PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_NAME + + +DEPENDENCIES = ['rflink'] + +_LOGGER = logging.getLogger(__name__) + + +CONF_ALIASES = 'aliases' +CONF_GROUP_ALIASES = 'group_aliases' +CONF_GROUP = 'group' +CONF_NOGROUP_ALIASES = 'nogroup_aliases' +CONF_DEVICE_DEFAULTS = 'device_defaults' +CONF_DEVICES = 'devices' +CONF_AUTOMATIC_ADD = 'automatic_add' +CONF_FIRE_EVENT = 'fire_event' +CONF_IGNORE_DEVICES = 'ignore_devices' +CONF_RECONNECT_INTERVAL = 'reconnect_interval' +CONF_SIGNAL_REPETITIONS = 'signal_repetitions' +CONF_WAIT_FOR_ACK = 'wait_for_ack' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({})): + DEVICE_DEFAULTS_SCHEMA, + vol.Optional(CONF_DEVICES, default={}): vol.Schema({ + cv.string: { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_GROUP_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_NOGROUP_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, + vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int), + vol.Optional(CONF_GROUP, default=True): cv.boolean, + }, + }), +}) + + +def devices_from_config(domain_config, hass=None): + """Parse configuration and add Rflink cover devices.""" + devices = [] + for device_id, config in domain_config[CONF_DEVICES].items(): + device_config = dict(domain_config[CONF_DEVICE_DEFAULTS], **config) + device = RflinkCover(device_id, hass, **device_config) + devices.append(device) + + # Register entity (and aliases) to listen to incoming rflink events + # Device id and normal aliases respond to normal and group command + hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_COMMAND][device_id].append(device) + if config[CONF_GROUP]: + hass.data[DATA_ENTITY_GROUP_LOOKUP][ + EVENT_KEY_COMMAND][device_id].append(device) + for _id in config[CONF_ALIASES]: + hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(device) + hass.data[DATA_ENTITY_GROUP_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(device) + return devices + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Rflink cover platform.""" + async_add_devices(devices_from_config(config, hass)) + + +class RflinkCover(RflinkCommand, CoverDevice): + """Rflink entity which can switch on/stop/off (eg: cover).""" + + def _handle_event(self, event): + """Adjust state if Rflink picks up a remote command for this device.""" + self.cancel_queued_send_commands() + + command = event['command'] + if command in ['on', 'allon']: + self._state = True + elif command in ['off', 'alloff']: + self._state = False + + @property + def should_poll(self): + """No polling available in RFlink cover.""" + return False + + @property + def is_closed(self): + """Return if the cover is closed.""" + return None + + def async_close_cover(self, **kwargs): + """Turn the device close.""" + return self._async_handle_command("close_cover") + + def async_open_cover(self, **kwargs): + """Turn the device open.""" + return self._async_handle_command("open_cover") + + def async_stop_cover(self, **kwargs): + """Turn the device stop.""" + return self._async_handle_command("stop_cover") diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py index 2e3ad7fff16cf2..4c79d19d38d4ae 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -24,7 +24,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.event import async_track_state_change -from homeassistant.helpers.restore_state import async_get_last_state from homeassistant.helpers.script import Script _LOGGER = logging.getLogger(__name__) @@ -134,7 +133,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): _LOGGER.error("No covers added") return False - async_add_devices(covers, True) + async_add_devices(covers) return True @@ -190,10 +189,6 @@ def __init__(self, hass, device_id, friendly_name, state_template, @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - state = yield from async_get_last_state(self.hass, self.entity_id) - if state: - self._position = 100 if state.state == STATE_OPEN else 0 - @callback def template_cover_state_listener(entity, old_state, new_state): """Handle target device state changes.""" diff --git a/homeassistant/components/cover/xiaomi.py b/homeassistant/components/cover/xiaomi_aqara.py similarity index 94% rename from homeassistant/components/cover/xiaomi.py rename to homeassistant/components/cover/xiaomi_aqara.py index d0e7bfa6d7eb78..17d056a5010a68 100644 --- a/homeassistant/components/cover/xiaomi.py +++ b/homeassistant/components/cover/xiaomi_aqara.py @@ -2,7 +2,8 @@ import logging from homeassistant.components.cover import CoverDevice -from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, + XiaomiDevice) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 2f1dde05bab4f7..b85c2d9a53b503 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -87,8 +87,8 @@ def async_setup(hass, config): # Set up input boolean tasks.append(bootstrap.async_setup_component( - hass, 'input_slider', - {'input_slider': { + hass, 'input_number', + {'input_number': { 'noise_allowance': {'icon': 'mdi:bell-ring', 'min': 0, 'max': 10, @@ -163,7 +163,7 @@ def async_setup(hass, config): 'scene.romantic_lights'])) tasks2.append(group.Group.async_create_group(hass, 'Bedroom', [ lights[0], switches[1], media_players[0], - 'input_slider.noise_allowance'])) + 'input_number.noise_allowance'])) tasks2.append(group.Group.async_create_group(hass, 'Kitchen', [ lights[2], 'cover.kitchen_window', 'lock.kitchen_door'])) tasks2.append(group.Group.async_create_group(hass, 'Doors', [ diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 8192dfa751de90..9a6dffc61017bb 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -18,11 +18,10 @@ from homeassistant.core import callback from homeassistant.loader import bind_hass from homeassistant.components import group, zone -from homeassistant.components.discovery import SERVICE_NETGEAR from homeassistant.config import load_yaml_config_file, async_log_exception from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import config_per_platform from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.restore_state import async_get_last_state @@ -89,10 +88,6 @@ cv.time_period, cv.positive_timedelta) }) -DISCOVERY_PLATFORMS = { - SERVICE_NETGEAR: 'netgear', -} - @bind_hass def is_on(hass: HomeAssistantType, entity_id: str=None): @@ -180,22 +175,6 @@ def async_setup_platform(p_type, p_config, disc_info=None): tracker.async_setup_group() - @callback - def async_device_tracker_discovered(service, info): - """Handle the discovery of device tracker platforms.""" - hass.async_add_job( - async_setup_platform(DISCOVERY_PLATFORMS[service], {}, info)) - - discovery.async_listen( - hass, DISCOVERY_PLATFORMS.keys(), async_device_tracker_discovered) - - @asyncio.coroutine - def async_platform_discovered(platform, info): - """Load a platform.""" - yield from async_setup_platform(platform, {}, disc_info=info) - - discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) - # Clean up stale devices async_track_utc_time_change( hass, tracker.async_update_stale, second=range(0, 60, 5)) diff --git a/homeassistant/components/device_tracker/aruba.py b/homeassistant/components/device_tracker/aruba.py index cef5eabd90158d..79d8806fe22bb1 100644 --- a/homeassistant/components/device_tracker/aruba.py +++ b/homeassistant/components/device_tracker/aruba.py @@ -19,9 +19,9 @@ REQUIREMENTS = ['pexpect==4.0.1'] _DEVICES_REGEX = re.compile( - r'(?P([^\s]+))\s+' + + r'(?P([^\s]+)?)\s+' + r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' + - r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s+') + r'(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))\s+') PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 6ae038fd41c286..05fe0b6997de20 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -23,7 +23,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval -REQUIREMENTS = ['aioautomatic==0.6.2'] +REQUIREMENTS = ['aioautomatic==0.6.3'] DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/fritz.py b/homeassistant/components/device_tracker/fritz.py index 5210329179ff08..58c23cb7d763b3 100644 --- a/homeassistant/components/device_tracker/fritz.py +++ b/homeassistant/components/device_tracker/fritz.py @@ -13,7 +13,7 @@ DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -REQUIREMENTS = ['fritzconnection==0.6.3'] +REQUIREMENTS = ['fritzconnection==0.6.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index e670287dd879d1..472b48fef6e48c 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -248,7 +248,7 @@ def icloud_verification_callback(self, callback_data): self._trusted_device, self._verification_code): raise PyiCloudException('Unknown failure') except PyiCloudException as error: - # Reset to the inital 2FA state to allow the user to retry + # Reset to the initial 2FA state to allow the user to retry _LOGGER.error("Failed to verify verification code: %s", error) self._trusted_device = None self._verification_code = None diff --git a/homeassistant/components/device_tracker/keenetic_ndms2.py b/homeassistant/components/device_tracker/keenetic_ndms2.py new file mode 100644 index 00000000000000..5a7db36e4798ab --- /dev/null +++ b/homeassistant/components/device_tracker/keenetic_ndms2.py @@ -0,0 +1,121 @@ +""" +Support for Zyxel Keenetic NDMS2 based routers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.keenetic_ndms2/ +""" +import logging +from collections import namedtuple + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME +) + +_LOGGER = logging.getLogger(__name__) + +# Interface name to track devices for. Most likely one will not need to +# change it from default 'Home'. This is needed not to track Guest WI-FI- +# clients and router itself +CONF_INTERFACE = 'interface' + +DEFAULT_INTERFACE = 'Home' + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string, +}) + + +def get_scanner(_hass, config): + """Validate the configuration and return a Nmap scanner.""" + scanner = KeeneticNDMS2DeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +Device = namedtuple('Device', ['mac', 'name']) + + +class KeeneticNDMS2DeviceScanner(DeviceScanner): + """This class scans for devices using keenetic NDMS2 web interface.""" + + def __init__(self, config): + """Initialize the scanner.""" + self.last_results = [] + + self._url = 'http://%s/rci/show/ip/arp' % config[CONF_HOST] + self._interface = config[CONF_INTERFACE] + + self._username = config.get(CONF_USERNAME) + self._password = config.get(CONF_PASSWORD) + + self.success_init = self._update_info() + _LOGGER.info("Scanner initialized") + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return [device.mac for device in self.last_results] + + def get_device_name(self, mac): + """Return the name of the given device or None if we don't know.""" + filter_named = [device.name for device in self.last_results + if device.mac == mac] + + if filter_named: + return filter_named[0] + return None + + def _update_info(self): + """Get ARP from keenetic router.""" + _LOGGER.info("Fetching...") + + last_results = [] + + # doing a request + try: + from requests.auth import HTTPDigestAuth + res = requests.get(self._url, timeout=10, auth=HTTPDigestAuth( + self._username, self._password + )) + except requests.exceptions.Timeout: + _LOGGER.error( + "Connection to the router timed out at URL %s", self._url) + return False + if res.status_code != 200: + _LOGGER.error( + "Connection failed with http code %s", res.status_code) + return False + try: + result = res.json() + except ValueError: + # If json decoder could not parse the response + _LOGGER.error("Failed to parse response from router") + return False + + # parsing response + for info in result: + if info.get('interface') != self._interface: + continue + mac = info.get('mac') + name = info.get('name') + # No address = no item :) + if mac is None: + continue + + last_results.append(Device(mac.upper(), name)) + + self.last_results = last_results + + _LOGGER.info("Request successful") + return True diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index 4e43b6ac10ddca..b445de116b9bb7 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -14,7 +14,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT) -REQUIREMENTS = ['librouteros==1.0.2'] +REQUIREMENTS = ['librouteros==1.0.4'] MTK_DEFAULT_API_PORT = '8728' @@ -83,6 +83,15 @@ def connect_to_device(self): routerboard_info[0].get('model', 'Router'), self.host) self.connected = True + self.capsman_exist = self.client( + cmd='/capsman/interface/getall' + ) + if not self.capsman_exist: + _LOGGER.info( + 'Mikrotik %s: Not a CAPSman controller. Trying ' + 'local interfaces ', + self.host + ) self.wireless_exist = self.client( cmd='/interface/wireless/getall' ) @@ -111,7 +120,9 @@ def get_device_name(self, mac): def _update_info(self): """Retrieve latest information from the Mikrotik box.""" - if self.wireless_exist: + if self.capsman_exist: + devices_tracker = 'capsman' + elif self.wireless_exist: devices_tracker = 'wireless' else: devices_tracker = 'ip' @@ -123,7 +134,11 @@ def _update_info(self): ) device_names = self.client(cmd='/ip/dhcp-server/lease/getall') - if self.wireless_exist: + if devices_tracker == 'capsman': + devices = self.client( + cmd='/caps-man/registration-table/getall' + ) + elif devices_tracker == 'wireless': devices = self.client( cmd='/interface/wireless/registration-table/getall' ) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 5c5c3c7c92e60e..ace6a251747332 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -1,30 +1,31 @@ """ -Support the OwnTracks platform. +Device tracker platform that adds support for OwnTracks over MQTT. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.owntracks/ """ import asyncio +import base64 import json import logging -import base64 from collections import defaultdict import voluptuous as vol -from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv import homeassistant.components.mqtt as mqtt -from homeassistant.const import STATE_HOME -from homeassistant.util import convert, slugify +import homeassistant.helpers.config_validation as cv from homeassistant.components import zone as zone_comp from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.const import STATE_HOME +from homeassistant.core import callback +from homeassistant.util import slugify, decorator -DEPENDENCIES = ['mqtt'] -REQUIREMENTS = ['libnacl==1.5.2'] +REQUIREMENTS = ['libnacl==1.6.0'] _LOGGER = logging.getLogger(__name__) +HANDLERS = decorator.Registry() + BEACON_DEV_ID = 'beacon' CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' @@ -32,17 +33,9 @@ CONF_WAYPOINT_IMPORT = 'waypoints' CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' -EVENT_TOPIC = 'owntracks/+/+/event' - -LOCATION_TOPIC = 'owntracks/+/+' - -VALIDATE_LOCATION = 'location' -VALIDATE_TRANSITION = 'transition' -VALIDATE_WAYPOINTS = 'waypoints' +DEPENDENCIES = ['mqtt'] -WAYPOINT_LAT_KEY = 'lat' -WAYPOINT_LON_KEY = 'lon' -WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoints' +OWNTRACKS_TOPIC = 'owntracks/#' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), @@ -72,300 +65,65 @@ def decrypt(ciphertext, key): @asyncio.coroutine def async_setup_scanner(hass, config, async_see, discovery_info=None): """Set up an OwnTracks tracker.""" - max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) - waypoint_import = config.get(CONF_WAYPOINT_IMPORT) - waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) - secret = config.get(CONF_SECRET) - - mobile_beacons_active = defaultdict(list) - regions_entered = defaultdict(list) + context = context_from_config(async_see, config) - def decrypt_payload(topic, ciphertext): - """Decrypt encrypted payload.""" + @asyncio.coroutine + def async_handle_mqtt_message(topic, payload, qos): + """Handle incoming OwnTracks message.""" try: - keylen, decrypt = get_cipher() - except OSError: - _LOGGER.warning( - "Ignoring encrypted payload because libsodium not installed") - return None - - if isinstance(secret, dict): - key = secret.get(topic) - else: - key = secret - - if key is None: - _LOGGER.warning( - "Ignoring encrypted payload because no decryption key known " - "for topic %s", topic) - return None - - key = key.encode("utf-8") - key = key[:keylen] - key = key.ljust(keylen, b'\0') - - try: - ciphertext = base64.b64decode(ciphertext) - message = decrypt(ciphertext, key) - message = message.decode("utf-8") - _LOGGER.debug("Decrypted payload: %s", message) - return message - except ValueError: - _LOGGER.warning( - "Ignoring encrypted payload because unable to decrypt using " - "key for topic %s", topic) - return None - - def validate_payload(topic, payload, data_type): - """Validate the OwnTracks payload.""" - try: - data = json.loads(payload) + message = json.loads(payload) except ValueError: # If invalid JSON _LOGGER.error("Unable to parse payload as JSON: %s", payload) - return None - - if isinstance(data, dict) and \ - data.get('_type') == 'encrypted' and \ - 'data' in data: - plaintext_payload = decrypt_payload(topic, data['data']) - if plaintext_payload is None: - return None - return validate_payload(topic, plaintext_payload, data_type) - - if not isinstance(data, dict) or data.get('_type') != data_type: - _LOGGER.debug("Skipping %s update for following data " - "because of missing or malformatted data: %s", - data_type, data) - return None - if data_type == VALIDATE_TRANSITION or data_type == VALIDATE_WAYPOINTS: - return data - if max_gps_accuracy is not None and \ - convert(data.get('acc'), float, 0.0) > max_gps_accuracy: - _LOGGER.info("Ignoring %s update because expected GPS " - "accuracy %s is not met: %s", - data_type, max_gps_accuracy, payload) - return None - if convert(data.get('acc'), float, 1.0) == 0.0: - _LOGGER.warning( - "Ignoring %s update because GPS accuracy is zero: %s", - data_type, payload) - return None - - return data - - @callback - def async_owntracks_location_update(topic, payload, qos): - """MQTT message received.""" - # Docs on available data: - # http://owntracks.org/booklet/tech/json/#_typelocation - data = validate_payload(topic, payload, VALIDATE_LOCATION) - if not data: return - dev_id, kwargs = _parse_see_args(topic, data) + message['topic'] = topic - if regions_entered[dev_id]: - _LOGGER.debug( - "Location update ignored, inside region %s", - regions_entered[-1]) - return - - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - @callback - def async_owntracks_event_update(topic, payload, qos): - """Handle MQTT event (geofences).""" - # Docs on available data: - # http://owntracks.org/booklet/tech/json/#_typetransition - data = validate_payload(topic, payload, VALIDATE_TRANSITION) - if not data: - return - - if data.get('desc') is None: - _LOGGER.error( - "Location missing from `Entering/Leaving` message - " - "please turn `Share` on in OwnTracks app") - return - # OwnTracks uses - at the start of a beacon zone - # to switch on 'hold mode' - ignore this - location = data['desc'].lstrip("-") - if location.lower() == 'home': - location = STATE_HOME - - dev_id, kwargs = _parse_see_args(topic, data) - - def enter_event(): - """Execute enter event.""" - zone = hass.states.get("zone.{}".format(slugify(location))) - if zone is None and data.get('t') == 'b': - # Not a HA zone, and a beacon so assume mobile - beacons = mobile_beacons_active[dev_id] - if location not in beacons: - beacons.append(location) - _LOGGER.info("Added beacon %s", location) - else: - # Normal region - regions = regions_entered[dev_id] - if location not in regions: - regions.append(location) - _LOGGER.info("Enter region %s", location) - _set_gps_from_zone(kwargs, location, zone) - - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - def leave_event(): - """Execute leave event.""" - regions = regions_entered[dev_id] - if location in regions: - regions.remove(location) - new_region = regions[-1] if regions else None - - if new_region: - # Exit to previous region - zone = hass.states.get( - "zone.{}".format(slugify(new_region))) - _set_gps_from_zone(kwargs, new_region, zone) - _LOGGER.info("Exit to %s", new_region) - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - else: - _LOGGER.info("Exit to GPS") - # Check for GPS accuracy - valid_gps = True - if 'acc' in data: - if data['acc'] == 0.0: - valid_gps = False - _LOGGER.warning( - "Ignoring GPS in region exit because accuracy" - "is zero: %s", payload) - if (max_gps_accuracy is not None and - data['acc'] > max_gps_accuracy): - valid_gps = False - _LOGGER.info( - "Ignoring GPS in region exit because expected " - "GPS accuracy %s is not met: %s", - max_gps_accuracy, payload) - if valid_gps: - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - beacons = mobile_beacons_active[dev_id] - if location in beacons: - beacons.remove(location) - _LOGGER.info("Remove beacon %s", location) - - if data['event'] == 'enter': - enter_event() - elif data['event'] == 'leave': - leave_event() - else: - _LOGGER.error( - "Misformatted mqtt msgs, _type=transition, event=%s", - data['event']) - return - - @callback - def async_owntracks_waypoint_update(topic, payload, qos): - """List of waypoints published by a user.""" - # Docs on available data: - # http://owntracks.org/booklet/tech/json/#_typewaypoints - data = validate_payload(topic, payload, VALIDATE_WAYPOINTS) - if not data: - return - - wayps = data['waypoints'] - _LOGGER.info("Got %d waypoints from %s", len(wayps), topic) - for wayp in wayps: - name = wayp['desc'] - pretty_name = parse_topic(topic, True)[1] + ' - ' + name - lat = wayp[WAYPOINT_LAT_KEY] - lon = wayp[WAYPOINT_LON_KEY] - rad = wayp['rad'] - - # check zone exists - entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name)) - - # Check if state already exists - if hass.states.get(entity_id) is not None: - continue - - zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, - zone_comp.ICON_IMPORT, False) - zone.entity_id = entity_id - hass.async_add_job(zone.async_update_ha_state()) - - @callback - def async_see_beacons(dev_id, kwargs_param): - """Set active beacons to the current location.""" - kwargs = kwargs_param.copy() - # the battery state applies to the tracking device, not the beacon - kwargs.pop('battery', None) - for beacon in mobile_beacons_active[dev_id]: - kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) - kwargs['host_name'] = beacon - hass.async_add_job(async_see(**kwargs)) + yield from async_handle_message(hass, context, message) yield from mqtt.async_subscribe( - hass, LOCATION_TOPIC, async_owntracks_location_update, 1) - yield from mqtt.async_subscribe( - hass, EVENT_TOPIC, async_owntracks_event_update, 1) - - if waypoint_import: - if waypoint_whitelist is None: - yield from mqtt.async_subscribe( - hass, WAYPOINT_TOPIC.format('+', '+'), - async_owntracks_waypoint_update, 1) - else: - for whitelist_user in waypoint_whitelist: - yield from mqtt.async_subscribe( - hass, WAYPOINT_TOPIC.format(whitelist_user, '+'), - async_owntracks_waypoint_update, 1) + hass, OWNTRACKS_TOPIC, async_handle_mqtt_message, 1) return True -def parse_topic(topic, pretty=False): +def _parse_topic(topic): """Parse an MQTT topic owntracks/user/dev, return (user, dev) tuple. Async friendly. """ - parts = topic.split('/') - dev_id_format = '' - if pretty: - dev_id_format = '{} {}' - else: - dev_id_format = '{}_{}' - dev_id = slugify(dev_id_format.format(parts[1], parts[2])) - host_name = parts[1] - return (host_name, dev_id) + try: + _, user, device, *_ = topic.split('/', 3) + except ValueError: + _LOGGER.error("Can't parse topic: '%s'", topic) + raise + return user, device -def _parse_see_args(topic, data): + +def _parse_see_args(message): """Parse the OwnTracks location parameters, into the format see expects. Async friendly. """ - (host_name, dev_id) = parse_topic(topic, False) + user, device = _parse_topic(message['topic']) + dev_id = slugify('{}_{}'.format(user, device)) kwargs = { 'dev_id': dev_id, - 'host_name': host_name, - 'gps': (data[WAYPOINT_LAT_KEY], data[WAYPOINT_LON_KEY]), + 'host_name': user, + 'gps': (message['lat'], message['lon']), 'attributes': {} } - if 'acc' in data: - kwargs['gps_accuracy'] = data['acc'] - if 'batt' in data: - kwargs['battery'] = data['batt'] - if 'vel' in data: - kwargs['attributes']['velocity'] = data['vel'] - if 'tid' in data: - kwargs['attributes']['tid'] = data['tid'] - if 'addr' in data: - kwargs['attributes']['address'] = data['addr'] + if 'acc' in message: + kwargs['gps_accuracy'] = message['acc'] + if 'batt' in message: + kwargs['battery'] = message['batt'] + if 'vel' in message: + kwargs['attributes']['velocity'] = message['vel'] + if 'tid' in message: + kwargs['attributes']['tid'] = message['tid'] + if 'addr' in message: + kwargs['attributes']['address'] = message['addr'] return dev_id, kwargs @@ -382,3 +140,288 @@ def _set_gps_from_zone(kwargs, location, zone): kwargs['gps_accuracy'] = zone.attributes['radius'] kwargs['location_name'] = location return kwargs + + +def _decrypt_payload(secret, topic, ciphertext): + """Decrypt encrypted payload.""" + try: + keylen, decrypt = get_cipher() + except OSError: + _LOGGER.warning( + "Ignoring encrypted payload because libsodium not installed") + return None + + if isinstance(secret, dict): + key = secret.get(topic) + else: + key = secret + + if key is None: + _LOGGER.warning( + "Ignoring encrypted payload because no decryption key known " + "for topic %s", topic) + return None + + key = key.encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + try: + ciphertext = base64.b64decode(ciphertext) + message = decrypt(ciphertext, key) + message = message.decode("utf-8") + _LOGGER.debug("Decrypted payload: %s", message) + return message + except ValueError: + _LOGGER.warning( + "Ignoring encrypted payload because unable to decrypt using " + "key for topic %s", topic) + return None + + +def context_from_config(async_see, config): + """Create an async context from Home Assistant config.""" + max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) + waypoint_import = config.get(CONF_WAYPOINT_IMPORT) + waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) + secret = config.get(CONF_SECRET) + + return OwnTracksContext(async_see, secret, max_gps_accuracy, + waypoint_import, waypoint_whitelist) + + +class OwnTracksContext: + """Hold the current OwnTracks context.""" + + def __init__(self, async_see, secret, max_gps_accuracy, import_waypoints, + waypoint_whitelist): + """Initialize an OwnTracks context.""" + self.async_see = async_see + self.secret = secret + self.max_gps_accuracy = max_gps_accuracy + self.mobile_beacons_active = defaultdict(list) + self.regions_entered = defaultdict(list) + self.import_waypoints = import_waypoints + self.waypoint_whitelist = waypoint_whitelist + + @callback + def async_valid_accuracy(self, message): + """Check if we should ignore this message.""" + acc = message.get('acc') + + if acc is None: + return False + + try: + acc = float(acc) + except ValueError: + return False + + if acc == 0: + _LOGGER.warning( + "Ignoring %s update because GPS accuracy is zero: %s", + message['_type'], message) + return False + + if self.max_gps_accuracy is not None and \ + acc > self.max_gps_accuracy: + _LOGGER.info("Ignoring %s update because expected GPS " + "accuracy %s is not met: %s", + message['_type'], self.max_gps_accuracy, + message) + return False + + return True + + @asyncio.coroutine + def async_see_beacons(self, dev_id, kwargs_param): + """Set active beacons to the current location.""" + kwargs = kwargs_param.copy() + # the battery state applies to the tracking device, not the beacon + kwargs.pop('battery', None) + for beacon in self.mobile_beacons_active[dev_id]: + kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) + kwargs['host_name'] = beacon + yield from self.async_see(**kwargs) + + +@HANDLERS.register('location') +@asyncio.coroutine +def async_handle_location_message(hass, context, message): + """Handle a location message.""" + if not context.async_valid_accuracy(message): + return + + dev_id, kwargs = _parse_see_args(message) + + if context.regions_entered[dev_id]: + _LOGGER.debug( + "Location update ignored, inside region %s", + context.regions_entered[-1]) + return + + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(dev_id, kwargs) + + +@asyncio.coroutine +def _async_transition_message_enter(hass, context, message, location): + """Execute enter event.""" + zone = hass.states.get("zone.{}".format(slugify(location))) + dev_id, kwargs = _parse_see_args(message) + + if zone is None and message.get('t') == 'b': + # Not a HA zone, and a beacon so assume mobile + beacons = context.mobile_beacons_active[dev_id] + if location not in beacons: + beacons.append(location) + _LOGGER.info("Added beacon %s", location) + else: + # Normal region + regions = context.regions_entered[dev_id] + if location not in regions: + regions.append(location) + _LOGGER.info("Enter region %s", location) + _set_gps_from_zone(kwargs, location, zone) + + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(dev_id, kwargs) + + +@asyncio.coroutine +def _async_transition_message_leave(hass, context, message, location): + """Execute leave event.""" + dev_id, kwargs = _parse_see_args(message) + regions = context.regions_entered[dev_id] + + if location in regions: + regions.remove(location) + + new_region = regions[-1] if regions else None + + if new_region: + # Exit to previous region + zone = hass.states.get( + "zone.{}".format(slugify(new_region))) + _set_gps_from_zone(kwargs, new_region, zone) + _LOGGER.info("Exit to %s", new_region) + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(dev_id, kwargs) + return + + else: + _LOGGER.info("Exit to GPS") + + # Check for GPS accuracy + if context.async_valid_accuracy(message): + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(dev_id, kwargs) + + beacons = context.mobile_beacons_active[dev_id] + if location in beacons: + beacons.remove(location) + _LOGGER.info("Remove beacon %s", location) + + +@HANDLERS.register('transition') +@asyncio.coroutine +def async_handle_transition_message(hass, context, message): + """Handle a transition message.""" + if message.get('desc') is None: + _LOGGER.error( + "Location missing from `Entering/Leaving` message - " + "please turn `Share` on in OwnTracks app") + return + # OwnTracks uses - at the start of a beacon zone + # to switch on 'hold mode' - ignore this + location = message['desc'].lstrip("-") + if location.lower() == 'home': + location = STATE_HOME + + if message['event'] == 'enter': + yield from _async_transition_message_enter( + hass, context, message, location) + elif message['event'] == 'leave': + yield from _async_transition_message_leave( + hass, context, message, location) + else: + _LOGGER.error( + "Misformatted mqtt msgs, _type=transition, event=%s", + message['event']) + + +@HANDLERS.register('waypoints') +@asyncio.coroutine +def async_handle_waypoints_message(hass, context, message): + """Handle a waypoints message.""" + if not context.import_waypoints: + return + + if context.waypoint_whitelist is not None: + user = _parse_topic(message['topic'])[0] + + if user not in context.waypoint_whitelist: + return + + wayps = message['waypoints'] + + _LOGGER.info("Got %d waypoints from %s", len(wayps), message['topic']) + + name_base = ' '.join(_parse_topic(message['topic'])) + + for wayp in wayps: + name = wayp['desc'] + pretty_name = '{} - {}'.format(name_base, name) + lat = wayp['lat'] + lon = wayp['lon'] + rad = wayp['rad'] + + # check zone exists + entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name)) + + # Check if state already exists + if hass.states.get(entity_id) is not None: + continue + + zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, + zone_comp.ICON_IMPORT, False) + zone.entity_id = entity_id + yield from zone.async_update_ha_state() + + +@HANDLERS.register('encrypted') +@asyncio.coroutine +def async_handle_encrypted_message(hass, context, message): + """Handle an encrypted message.""" + plaintext_payload = _decrypt_payload(context.secret, message['topic'], + message['data']) + + if plaintext_payload is None: + return + + decrypted = json.loads(plaintext_payload) + decrypted['topic'] = message['topic'] + + yield from async_handle_message(hass, context, decrypted) + + +@HANDLERS.register('lwt') +@asyncio.coroutine +def async_handle_lwt_message(hass, context, message): + """Handle an lwt message.""" + _LOGGER.debug('Not handling lwt message: %s', message) + + +@asyncio.coroutine +def async_handle_message(hass, context, message): + """Handle an OwnTracks message.""" + msgtype = message.get('_type') + + handler = HANDLERS.get(msgtype) + + if handler is None: + _LOGGER.warning( + 'Received unsupported message type: %s.', msgtype) + return + + yield from handler(hass, context, message) diff --git a/homeassistant/components/device_tracker/owntracks_http.py b/homeassistant/components/device_tracker/owntracks_http.py new file mode 100644 index 00000000000000..dcc3300cc12e78 --- /dev/null +++ b/homeassistant/components/device_tracker/owntracks_http.py @@ -0,0 +1,54 @@ +""" +Device tracker platform that adds support for OwnTracks over HTTP. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.owntracks_http/ +""" +import asyncio + +from aiohttp.web_exceptions import HTTPInternalServerError + +from homeassistant.components.http import HomeAssistantView + +# pylint: disable=unused-import +from .owntracks import ( # NOQA + REQUIREMENTS, PLATFORM_SCHEMA, context_from_config, async_handle_message) + + +DEPENDENCIES = ['http'] + + +@asyncio.coroutine +def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Set up an OwnTracks tracker.""" + context = context_from_config(async_see, config) + + hass.http.register_view(OwnTracksView(context)) + + return True + + +class OwnTracksView(HomeAssistantView): + """View to handle OwnTracks HTTP requests.""" + + url = '/api/owntracks/{user}/{device}' + name = 'api:owntracks' + + def __init__(self, context): + """Initialize OwnTracks URL endpoints.""" + self.context = context + + @asyncio.coroutine + def post(self, request, user, device): + """Handle an OwnTracks message.""" + hass = request.app['hass'] + + message = yield from request.json() + message['topic'] = 'owntracks/{}/{}'.format(user, device) + + try: + yield from async_handle_message(hass, self.context, message) + return self.json([]) + + except ValueError: + raise HTTPInternalServerError diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index 3efae2b9ce2e5d..d0cfcff20efc69 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pysnmp==4.3.9'] +REQUIREMENTS = ['pysnmp==4.3.10'] CONF_COMMUNITY = 'community' CONF_AUTHKEY = 'authkey' @@ -36,7 +36,7 @@ # pylint: disable=unused-argument def get_scanner(hass, config): - """Validate the configuration and return an snmp scanner.""" + """Validate the configuration and return an SNMP scanner.""" scanner = SnmpScanner(config[DOMAIN]) return scanner if scanner.success_init else None @@ -75,7 +75,7 @@ def scan_devices(self): return [client['mac'] for client in self.last_results if client.get('mac')] - # Supressing no-self-use warning + # Suppressing no-self-use warning # pylint: disable=R0201 def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index a471ca5c96a10d..a3e81b3ef5110c 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -5,6 +5,7 @@ https://home-assistant.io/components/device_tracker.unifi/ """ import logging +from datetime import timedelta import voluptuous as vol import homeassistant.helpers.config_validation as cv @@ -12,16 +13,19 @@ DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD from homeassistant.const import CONF_VERIFY_SSL +import homeassistant.util.dt as dt_util REQUIREMENTS = ['pyunifi==2.13'] _LOGGER = logging.getLogger(__name__) CONF_PORT = 'port' CONF_SITE_ID = 'site_id' +CONF_DETECTION_TIME = 'detection_time' DEFAULT_HOST = 'localhost' DEFAULT_PORT = 8443 DEFAULT_VERIFY_SSL = True +DEFAULT_DETECTION_TIME = timedelta(seconds=300) NOTIFICATION_ID = 'unifi_notification' NOTIFICATION_TITLE = 'Unifi Device Tracker Setup' @@ -32,7 +36,10 @@ vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): vol.Any( + cv.boolean, cv.isfile), + vol.Optional(CONF_DETECTION_TIME, default=DEFAULT_DETECTION_TIME): vol.All( + cv.time_period, cv.positive_timedelta) }) @@ -46,6 +53,7 @@ def get_scanner(hass, config): site_id = config[DOMAIN].get(CONF_SITE_ID) port = config[DOMAIN].get(CONF_PORT) verify_ssl = config[DOMAIN].get(CONF_VERIFY_SSL) + detection_time = config[DOMAIN].get(CONF_DETECTION_TIME) try: ctrl = Controller(host, username, password, port, version='v4', @@ -61,14 +69,15 @@ def get_scanner(hass, config): notification_id=NOTIFICATION_ID) return False - return UnifiScanner(ctrl) + return UnifiScanner(ctrl, detection_time) class UnifiScanner(DeviceScanner): """Provide device_tracker support from Unifi WAP client data.""" - def __init__(self, controller): + def __init__(self, controller, detection_time: timedelta): """Initialize the scanner.""" + self._detection_time = detection_time self._controller = controller self._update() @@ -81,7 +90,11 @@ def _update(self): _LOGGER.error("Failed to scan clients: %s", ex) clients = [] - self._clients = {client['mac']: client for client in clients} + self._clients = { + client['mac']: client + for client in clients + if (dt_util.utcnow() - dt_util.utc_from_timestamp(float( + client['last_seen']))) < self._detection_time} def scan_devices(self): """Scan for devices.""" @@ -96,5 +109,5 @@ def get_device_name(self, mac): """ client = self._clients.get(mac, {}) name = client.get('name') or client.get('hostname') - _LOGGER.debug("Device %s name %s", mac, name) + _LOGGER.debug("Device mac %s name %s", mac, name) return name diff --git a/homeassistant/components/device_tracker/upc_connect.py b/homeassistant/components/device_tracker/upc_connect.py index a6646c8d0a14d4..338ce34048e121 100644 --- a/homeassistant/components/device_tracker/upc_connect.py +++ b/homeassistant/components/device_tracker/upc_connect.py @@ -6,7 +6,6 @@ """ import asyncio import logging -import xml.etree.ElementTree as ET import aiohttp import async_timeout @@ -19,6 +18,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession +REQUIREMENTS = ['defusedxml==0.5.0'] + _LOGGER = logging.getLogger(__name__) DEFAULT_IP = '192.168.0.1' @@ -63,6 +64,8 @@ def __init__(self, hass, config): @asyncio.coroutine def async_scan_devices(self): """Scan for new devices and return a list with found device IDs.""" + import defusedxml.ElementTree as ET + if self.token is None: token_initialized = yield from self.async_initialize_token() if not token_initialized: diff --git a/homeassistant/components/device_tracker/xiaomi.py b/homeassistant/components/device_tracker/xiaomi.py index 8b8db3da2d8b88..12e64b724dd370 100644 --- a/homeassistant/components/device_tracker/xiaomi.py +++ b/homeassistant/components/device_tracker/xiaomi.py @@ -69,7 +69,7 @@ def get_device_name(self, device): return self.mac2name.get(device.upper(), None) def _update_info(self): - """Ensure the informations from the router are up to date. + """Ensure the information from the router are up to date. Returns true if scanning successful. """ diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 1f8b12eef6b7f7..50cc771ffd37be 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -21,7 +21,7 @@ from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==1.2.0'] +REQUIREMENTS = ['netdisco==1.2.2'] DOMAIN = 'discovery' @@ -45,7 +45,7 @@ SERVICE_AXIS: ('axis', None), SERVICE_APPLE_TV: ('apple_tv', None), SERVICE_WINK: ('wink', None), - SERVICE_XIAOMI_GW: ('xiaomi', None), + SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), 'philips_hue': ('light', 'hue'), 'google_cast': ('media_player', 'cast'), 'panasonic_viera': ('media_player', 'panasonic_viera'), diff --git a/homeassistant/components/doorbird.py b/homeassistant/components/doorbird.py new file mode 100644 index 00000000000000..421c85a0f94b02 --- /dev/null +++ b/homeassistant/components/doorbird.py @@ -0,0 +1,44 @@ +"""Support for a DoorBird video doorbell.""" + +import logging +import voluptuous as vol + +from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['DoorBirdPy==0.0.4'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'doorbird' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the DoorBird component.""" + device_ip = config[DOMAIN].get(CONF_HOST) + username = config[DOMAIN].get(CONF_USERNAME) + password = config[DOMAIN].get(CONF_PASSWORD) + + from doorbirdpy import DoorBird + device = DoorBird(device_ip, username, password) + status = device.ready() + + if status[0]: + _LOGGER.info("Connected to DoorBird at %s as %s", device_ip, username) + hass.data[DOMAIN] = device + return True + elif status[1] == 401: + _LOGGER.error("Authorization rejected by DoorBird at %s", device_ip) + return False + else: + _LOGGER.error("Could not connect to DoorBird at %s: Error %s", + device_ip, str(status[1])) + return False diff --git a/homeassistant/components/downloader.py b/homeassistant/components/downloader.py index 2e26b306673bcd..0450ba175ee0f0 100644 --- a/homeassistant/components/downloader.py +++ b/homeassistant/components/downloader.py @@ -122,7 +122,7 @@ def do_download(): _LOGGER.info("Downloading of %s done", url) except requests.exceptions.ConnectionError: - _LOGGER.exception("ConnectionError occured for %s", url) + _LOGGER.exception("ConnectionError occurred for %s", url) # Remove file if we started downloading but failed if final_path and os.path.isfile(final_path): diff --git a/homeassistant/components/duckdns.py b/homeassistant/components/duckdns.py new file mode 100644 index 00000000000000..0045b9421a2dbf --- /dev/null +++ b/homeassistant/components/duckdns.py @@ -0,0 +1,102 @@ +"""Integrate with DuckDNS.""" +import asyncio +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN +from homeassistant.loader import bind_hass +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +DOMAIN = 'duckdns' +UPDATE_URL = 'https://www.duckdns.org/update' +INTERVAL = timedelta(minutes=5) +_LOGGER = logging.getLogger(__name__) +SERVICE_SET_TXT = 'set_txt' +ATTR_TXT = 'txt' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + +SERVICE_TXT_SCHEMA = vol.Schema({ + vol.Required(ATTR_TXT): vol.Any(None, cv.string) +}) + + +@bind_hass +@asyncio.coroutine +def async_set_txt(hass, txt): + """Set the txt record. Pass in None to remove it.""" + yield from hass.services.async_call(DOMAIN, SERVICE_SET_TXT, { + ATTR_TXT: txt + }, blocking=True) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the DuckDNS component.""" + domain = config[DOMAIN][CONF_DOMAIN] + token = config[DOMAIN][CONF_ACCESS_TOKEN] + session = async_get_clientsession(hass) + + result = yield from _update_duckdns(session, domain, token) + + if not result: + return False + + @asyncio.coroutine + def update_domain_interval(now): + """Update the DuckDNS entry.""" + yield from _update_duckdns(session, domain, token) + + @asyncio.coroutine + def update_domain_service(call): + """Update the DuckDNS entry.""" + yield from _update_duckdns(session, domain, token, + txt=call.data[ATTR_TXT]) + + async_track_time_interval(hass, update_domain_interval, INTERVAL) + hass.services.async_register( + DOMAIN, SERVICE_SET_TXT, update_domain_service, + schema=SERVICE_TXT_SCHEMA) + + return result + + +_SENTINEL = object() + + +@asyncio.coroutine +def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False): + """Update DuckDNS.""" + params = { + 'domains': domain, + 'token': token, + } + + if txt is not _SENTINEL: + if txt is None: + # Pass in empty txt value to indicate it's clearing txt record + params['txt'] = '' + clear = True + else: + params['txt'] = txt + + if clear: + params['clear'] = 'true' + + resp = yield from session.get(UPDATE_URL, params=params) + body = yield from resp.text() + + if body != 'OK': + _LOGGER.warning('Updating DuckDNS domain %s failed', domain) + return False + + return True diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index c4b0f2e9546e40..0b0c9d1d65a36a 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_API_KEY from homeassistant.util import Throttle -REQUIREMENTS = ['python-ecobee-api==0.0.9'] +REQUIREMENTS = ['python-ecobee-api==0.0.10'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index ae0a26aaea4ae9..a83f5337caee39 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -16,6 +16,7 @@ ) from homeassistant.components.http import REQUIREMENTS # NOQA from homeassistant.components.http import HomeAssistantWSGI +from homeassistant.helpers.deprecation import get_deprecated import homeassistant.helpers.config_validation as cv from .hue_api import ( HueUsernameView, HueAllLightsStateView, HueOneLightStateView, @@ -66,6 +67,7 @@ }, extra=vol.ALLOW_EXTRA) ATTR_EMULATED_HUE = 'emulated_hue' +ATTR_EMULATED_HUE_HIDDEN = 'emulated_hue_hidden' def setup(hass, yaml_config): @@ -129,7 +131,7 @@ def __init__(self, hass, conf): if self.type == TYPE_ALEXA: _LOGGER.warning("Alexa type is deprecated and will be removed in a" - "future version") + " future version") # Get the IP address that will be passed to the Echo during discovery self.host_ip_addr = conf.get(CONF_HOST_IP) @@ -148,7 +150,7 @@ def __init__(self, hass, conf): self.listen_port) if self.type == TYPE_GOOGLE and self.listen_port != 80: - _LOGGER.warning("When targetting Google Home, listening port has " + _LOGGER.warning("When targeting Google Home, listening port has " "to be port 80") # Get whether or not UPNP binds to multicast address (239.255.255.250) @@ -223,7 +225,15 @@ def is_entity_exposed(self, entity): domain = entity.domain.lower() explicit_expose = entity.attributes.get(ATTR_EMULATED_HUE, None) - + explicit_hidden = entity.attributes.get(ATTR_EMULATED_HUE_HIDDEN, None) + if explicit_expose is True or explicit_hidden is False: + expose = True + elif explicit_expose is False or explicit_hidden is True: + expose = False + else: + expose = None + get_deprecated(entity.attributes, ATTR_EMULATED_HUE_HIDDEN, + ATTR_EMULATED_HUE, None) domain_exposed_by_default = \ self.expose_by_default and domain in self.exposed_domains @@ -231,9 +241,9 @@ def is_entity_exposed(self, entity): # the configuration doesn't explicitly exclude it from being # exposed, or if the entity is explicitly exposed is_default_exposed = \ - domain_exposed_by_default and explicit_expose is not False + domain_exposed_by_default and expose is not False - return is_default_exposed or explicit_expose + return is_default_exposed or expose def _load_numbers_json(self): """Set up helper method to load numbers json.""" diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index f8d414240649c0..548b6f3d771970 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -1,4 +1,4 @@ -"""Provides a UPNP discovery method that mimicks Hue hubs.""" +"""Provides a UPNP discovery method that mimics Hue hubs.""" import threading import socket import logging @@ -123,20 +123,20 @@ def run(self): if ssdp_socket in read: data, addr = ssdp_socket.recvfrom(1024) else: - # most likely the timeout, so check for interupt + # most likely the timeout, so check for interrupt continue except socket.error as ex: if self._interrupted: clean_socket_close(ssdp_socket) return - _LOGGER.error("UPNP Responder socket exception occured: %s", + _LOGGER.error("UPNP Responder socket exception occurred: %s", ex.__str__) # without the following continue, a second exception occurs # because the data object has not been initialized continue - if "M-SEARCH" in data.decode('utf-8'): + if "M-SEARCH" in data.decode('utf-8', errors='ignore'): # SSDP M-SEARCH method received, respond to it with our info resp_socket = socket.socket( socket.AF_INET, socket.SOCK_DGRAM) diff --git a/homeassistant/components/enocean.py b/homeassistant/components/enocean.py index 79c2e3dce8d3e4..3c3eefe54ccf8a 100644 --- a/homeassistant/components/enocean.py +++ b/homeassistant/components/enocean.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_DEVICE import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['enocean==0.31'] +REQUIREMENTS = ['enocean==0.40'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index fd12529cb486de..7710040ae990aa 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -215,20 +215,12 @@ def async_handle_fan_service(service): target_fans = component.async_extract_from_service(service) params.pop(ATTR_ENTITY_ID, None) - for fan in target_fans: - yield from getattr(fan, method['method'])(**params) - update_tasks = [] - for fan in target_fans: + yield from getattr(fan, method['method'])(**params) if not fan.should_poll: continue - - update_coro = hass.async_add_job(fan.async_update_ha_state(True)) - if hasattr(fan, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(fan.async_update_ha_state(True)) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) diff --git a/homeassistant/components/fan/insteon_local.py b/homeassistant/components/fan/insteon_local.py index 5bdfec084279a1..e12e3476c3a193 100644 --- a/homeassistant/components/fan/insteon_local.py +++ b/homeassistant/components/fan/insteon_local.py @@ -137,7 +137,7 @@ def __init__(self, node, name): @property def name(self): - """Return the the name of the node.""" + """Return the name of the node.""" return self.node.deviceName @property diff --git a/homeassistant/components/fan/isy994.py b/homeassistant/components/fan/isy994.py index 90cd161fa2000c..a49952569a8742 100644 --- a/homeassistant/components/fan/isy994.py +++ b/homeassistant/components/fan/isy994.py @@ -11,7 +11,7 @@ SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH) import homeassistant.components.isy994 as isy -from homeassistant.const import STATE_UNKNOWN, STATE_ON, STATE_OFF +from homeassistant.const import STATE_ON, STATE_OFF from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -73,19 +73,16 @@ def __init__(self, node) -> None: @property def speed(self) -> str: """Return the current speed.""" - return self.state + return VALUE_TO_STATE.get(self.value) @property - def state(self) -> str: - """Get the state of the ISY994 fan device.""" - return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) + def is_on(self) -> str: + """Get if the fan is on.""" + return self.value != 0 def set_speed(self, speed: str) -> None: """Send the set speed command to the ISY994 fan device.""" - if not self._node.on(val=STATE_TO_VALUE.get(speed, 0)): - _LOGGER.debug("Unable to set fan speed") - else: - self.speed = self.state + self._node.on(val=STATE_TO_VALUE.get(speed, 255)) def turn_on(self, speed: str=None, **kwargs) -> None: """Send the turn on command to the ISY994 fan device.""" @@ -93,10 +90,7 @@ def turn_on(self, speed: str=None, **kwargs) -> None: def turn_off(self, **kwargs) -> None: """Send the turn off command to the ISY994 fan device.""" - if not self._node.off(): - _LOGGER.debug("Unable to set fan speed") - else: - self.speed = self.state + self._node.off() @property def speed_list(self) -> list: diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index 58ac08ce16f0b6..e76e11d4786825 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -78,6 +78,9 @@ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the MQTT fan platform.""" + if discovery_info is not None: + config = PLATFORM_SCHEMA(discovery_info) + async_add_devices([MqttFan( config.get(CONF_NAME), { diff --git a/homeassistant/components/ffmpeg.py b/homeassistant/components/ffmpeg.py index f5efa1ef6238a1..dc0439b8b32234 100644 --- a/homeassistant/components/ffmpeg.py +++ b/homeassistant/components/ffmpeg.py @@ -19,7 +19,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['ha-ffmpeg==1.7'] +REQUIREMENTS = ['ha-ffmpeg==1.9'] DOMAIN = 'ffmpeg' diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 112c93403b007a..941de4574cffba 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -225,8 +225,6 @@ def setup(hass, config): if DATA_EXTRA_HTML_URL not in hass.data: hass.data[DATA_EXTRA_HTML_URL] = set() - register_built_in_panel(hass, 'map', 'Map', 'mdi:account-location') - for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', 'dev-template', 'dev-mqtt', 'kiosk'): register_built_in_panel(hass, panel) @@ -329,6 +327,7 @@ def __init__(self): from jinja2 import FileSystemLoader, Environment self.templates = Environment( + autoescape=True, loader=FileSystemLoader( os.path.join(os.path.dirname(__file__), 'templates/') ) diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html index 6d199a86a507c4..70e7e777510a7b 100644 --- a/homeassistant/components/frontend/templates/index.html +++ b/homeassistant/components/frontend/templates/index.html @@ -92,27 +92,25 @@ {% if not dev_mode %} {% endif %} - - {% if panel_url -%} - - {% endif -%} - - {% for extra_url in extra_urls -%} - - {% endfor -%} - + + {% if panel_url -%} + + {% endif -%} + + {% for extra_url in extra_urls -%} + + {% endfor -%} diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 87ccbf550755ba..052bd7e86feeb3 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -3,10 +3,10 @@ FINGERPRINTS = { "compatibility.js": "1686167ff210e001f063f5c606b2e74b", "core.js": "2a7d01e45187c7d4635da05065b5e54e", - "frontend.html": "6b0a95408d9ee869d0fe20c374077ed4", - "mdi.html": "89074face5529f5fe6fbae49ecb3e88b", + "frontend.html": "2de1bde3b4a6c6c47dd95504fc098906", + "mdi.html": "2e848b4da029bf73d426d5ba058a088d", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", - "panels/ha-panel-config.html": "0b985cbf668b16bca9f34727036c7139", + "panels/ha-panel-config.html": "52e2e1d477bfd6dc3708d65b8337f0af", "panels/ha-panel-dev-event.html": "d409e7ab537d9fe629126d122345279c", "panels/ha-panel-dev-info.html": "b0e55eb657fd75f21aba2426ac0cedc0", "panels/ha-panel-dev-mqtt.html": "94b222b013a98583842de3e72d5888c6", diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 2dc0bb5f156d8f..c873d66777e4e4 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -8,7 +8,7 @@ .flex-1{-ms-flex:1 1 0.000000001px;-webkit-flex:1;flex:1;-webkit-flex-basis:0.000000001px;flex-basis:0.000000001px;}.flex-2{-ms-flex:2;-webkit-flex:2;flex:2;}.flex-3{-ms-flex:3;-webkit-flex:3;flex:3;}.flex-4{-ms-flex:4;-webkit-flex:4;flex:4;}.flex-5{-ms-flex:5;-webkit-flex:5;flex:5;}.flex-6{-ms-flex:6;-webkit-flex:6;flex:6;}.flex-7{-ms-flex:7;-webkit-flex:7;flex:7;}.flex-8{-ms-flex:8;-webkit-flex:8;flex:8;}.flex-9{-ms-flex:9;-webkit-flex:9;flex:9;}.flex-10{-ms-flex:10;-webkit-flex:10;flex:10;}.flex-11{-ms-flex:11;-webkit-flex:11;flex:11;}.flex-12{-ms-flex:12;-webkit-flex:12;flex:12;}