From 1b46ed504521eb946973a42b9275ea7084a1b524 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 11 Sep 2016 03:22:58 +0200 Subject: [PATCH 01/13] 0.28 (#3288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Backend support for importing waypoints from owntracks as HA zones * Added test for Owntracks waypoints import * Backend support for importing waypoints from owntracks as HA zones * Added test for Owntracks waypoints import * Removed redundant assignment to CONF_WAYPOINT_IMPORT_USER * Fixed zone test break and code style issues * Fixed style issues * Fixed variable scope issues for entities * Fixed E302 * Do not install pip packages in tests * EventBus: return function to unlisten * Convert automation to entities with services * Refactored zone creation based on code review feedback, enhanced configuration * Added unit test to enhance waypoint_whitelist coverage * Fix JSON encoder issue in recorder * Fix tests docstring * * Improved zone naming in waypoint import * Added more test coverage for owntracks and zone * Back to 0.28.0.dev0 * Code review feedback from @pavoni * Added bitfield of features for flux_led since we are supporting effects * Host should be optional for apcupsd component (#3072) * Use voluptuous for file (#3049) * Zwave climate Bugfix: if some setpoints have different units, we should fetch the o… (#3078) * Bugfix: if some setpoints have different units, we should fetch the one that are active. * Move order of population for first time detection * Default to config if None unit_of_measurement * unit fix (#3083) * humidity slider (#3088) * If device was off target temp was null. Default to Heating setpoint (#3091) * Fix linting * Upgrade pyuserinput to 0.1.11 (#3068) * Upgrade pyowm to 2.4.0 (#3067) * improve isfile validation check (#3101) * Refactor notification titles to allow for them to be None, this also includes a change in Telegram to only include the title if it's present, and to use a Markdown parse mode for messages (#3100) * Fix broken test * rfxtrx sensor clean up * Bitcoin sensor use warning instead of error (#3103) * Use voluptuous for HDMI CEC & CONF_DEVICES constants (#3107) * Update voluptuous for nest (#3109) * Update configuration check * Extend platform * Fix for BLE device tracker (#3019) * Bug fix tracked devices * Added scan_duration configuration parameter * fix homematic climate implementation (#3114) * Allow 'None' MAC to be loaded from known_devices (#3102) * Use voluptuous for xmpp (#3127) * Use voluptuous for twitter (#3126) * Use voluptuous for Fritzbox and DDWRT (#3122) * Use Voluptuous for BT Home Hub (#3121) * Use voluptuous for syslog (#3120) * Use voluptuous for Aruba (#3119) * Use constants, update configuration check, and ordering (Pilight) (#3118) * Use contants, update configuration check, and ordering * Fix pylint issue * Migrate to voluptuous (#3113) * Fix typo (#3108) * Migrate to voluptuous (#3106) * Update voluptuous (#3104) * Climate and cover bugfix (#3097) * Avoid None comparison for zwave cover. * Just rely on unit from config for unit_of_measurement * Explicit return None * Mqtt (#11) * Explicit return None * Missing service and wrong service name defined * Mqtt state was inverted, and never triggering * Migrate to voluptuous (#3096) * Migrate to voluptuous (#3084) * Fixed Homematic cover (#3116) * Migrate to voluptuous (#3069) 🐬 * Migrate to voluptuous (#3066) 🐬 * snapcast update (#3012) * snapcast update * snapcast update * validate config * use conf constants * orvibo updates (#3006) 🐬 * Update frontend * move units to temperature for climate zwave. wrong state was sent to mqtt cove * Use voluptuous for instapush (#3132) * Use voluptuous for Octoprint (#3111) * Migrate to voluptuous * Fix pylint issues * Add missing docstrings (fix PEP257 issues) (#3098) * Add missing docstrings (fix PEP257 issues) * Finish sentence * Updated braviatv's braviarc version to 0.3.4 (#2997) * Updated braviarc version to 0.3.4 * Updated braviarc version to requirements_all.txt * Use voluptuous for Acer projector switch (#3077) 🐬 * Use voluptuous for twilio (#3134) * Use voluptuous for webostv (#3135) * Use voluptuous for Command line platforms (#2968) * Migrate to voluptuous * Fix pylint issues * Remove FIXME * Split setup test * Test with bootstrap * Remove lon and lat * Fix pylint issues * Add coinmarketcap sensor (#3064) * Migrate to voluptuous (#3142) 🐬 * Back out insteon hub and fan changes (#3062) * Move details to docs (#3146) * Update frontend * Use constants (#3148) * Update ordering (#3149) * Migrate to voluptuous (#3092) * Display the error instead of the traceback (notify.slack) (#3079) * Display the error instead of the traceback * Remove name for check * Automatic ODB device tracker & device tracker attributes (#3035) * Migrate to voluptuous (#3173) * Add voluptuous for tomato and SNMP (#3172) * Improve voluptuous and login errors for Asus device tracker (#3170) * Add exclude option to nmap device tracker (#2983) * Add exclude option to nmap device tracker Adds an optional exclude paramater to nmap device tracker. Devices specified in the exclude list will never be scanned by nmap. This can help to reduce log spam. ex: ``` device_tracker: - platform: nmap_tracker hosts: 10.0.0.1/24 home_interval: 1 interval_seconds: 12 consider_home: 120 track_new_devices: yes exclude: - 10.0.0.2 - 10.0.0.1 ``` * Handle optional exclude * Style fixed * Added Xbox Live component (#3013) * Added Xbox Live component * Added Xbox Live sensor to coveralls * Added init success checks * Added entity id * Adding link_names to post.message call (#3167) If you do not turn link_names on, Slack will not highlight @channel and @username messages. * Allow https (fixes #3150) (#3155) * Use constants (#3156) * Bugfix: ctach Runtime errors (#3153) "RuntimeError: Disable scan failed" has been seen in a live installation * Migrate to voluptuous (#3166) 🐬 * Migrate to voluptuous (#3164) 🐬 * Migrate to voluptuous (#3163) 🐬 * Migrate to voluptuous (#3162) 🐬 and 🍪 for fixing quotes! * Exclude www_static from pydocstyle linting (#3175) 🐬 * Migrate to voluptuous (#3174) * Migrate to voluptuous (#3171) * Use voluptuous for mFi switch (#3168) * Migrate to voluptuous * Take change configuration into account * Migrate to voluptuous (#3144) 🐬 * Add the occupancy sensor_class (#3176) Such a complicated PR * Update frontend * Use voluptuous for Unifi, Ubus (#3125) * Using alert with Hue maintains prior state (#3147) * When using flash with hue, dont change the on/off state of the light so that it will naturally return to its previous state once flash is complete * ATTR_FLASH not ATTR_EFFECT * MQTT fan platform (#3095) * Add fan.mqtt, allow brightness to be passed and mapped to a fan speed for compatibility with emulated_hue * Pylint/Flake8 fixes * Remove brightness * Add more features, like custom oscillation/speed payloads and setting the speed list * Flake8 fixes * flake8/pylint fixes * Use constants * block fan.mqtt from coverage * Fix oscillating comment * Add Sphinx API doc generation (#3029) * add's sphinx project to docs/ dir * include core/helpers autodocs for API reference * Allow reloading automation without restarting HA (#3002) * Migrate to voluptuous (#3182) 🐬 * Migrate to voluptuous (#3179) 🐬 * Added scale and offset to the Temper component (#2853) 🐬 * Use voluptuous for BT and Owntracks device trackers (#3187) 🐬 * Correct binary_sensor.ecobee docs URL * Use voluptuous for Hikvisioncam switch (#3184) * Migrate to voluptuous * Use vol.Optional * Use voluptuous for Edimax (#3178) 🐬 * Use voluptuous for Bravia TV (#3165) 🐬 * Added support to 'effect: random' to Osram Lightify lights (#3192) * Added support to 'effect: random' to Osram Lightify lights * removed extra line not required * Use voluptuous for message_bird, sendgrid (#3136) * Try out the RTD theme * Doc updates * Update voluptuous for existing notify platforms (#3133) * Update voluptuous for exists notify platforms * fix constants * Simple trend sensor. (#3073) * First cut of trend sensor. * Tidy. * Migrate to voluptuous (#3193) * Migrate to voluptuous (#3194) 🐬 * Migrate to voluptuous (#3197) * Migrate to voluptuous (#3198) 🐬 * Use extend of PLATFORM_SCHEMA (#3199) * Migrate to voluptuous (#3202) 🐬 * Updated to use the occupancy sensor_class (#3204) 🐬 * Migrate to voluptuous (#3206) * Migrate to voluptuous (#3207) * Migrate to voluptuous (#3208) 🐬 * Migrate to voluptuous (#3209) 🐬 * Migrate to voluptuous (#3214) * Use voluptuous for SqueezeBox (#3212) * Migrate to voluptuous * Remove name * Migrate to voluptuous and upgrade uber_rides to 0.2.5 (#3181) * Migrate to voluptuous (#3200) 🐬 * Use Voluptuous for Luci and Netgear device trackers (#3123) * Use Voluptuous for Luci and NEtgear device trackers * str_schema shortcut * Undo str_schema * change update handling with variable for breack CCU2 (#3215) * Update ordering (#3216) * Docs update * Flake8/pylint * Add new docs requirements * Update email validation (#3228) 🐬 * Fix email validation (fixes #3138) (#3227) * Upgrade slacker to 0.9.25 (#3224) * Upgrade psutil to 4.3.1 (#3223) * Upgrade gps3 to 0.33.3 (#3222) * Upgrade Werkzeug to 0.11.11 (#3220) * Upgrade sendgrid to 3.4.0 (#3226) * Bluetooth: keep looking for new devices (#3201) * keep looking for new devices * Update bluetooth_tracker.py * change default value for tracking new devices * remove commented code * dlink switch added device state attributes and support for legacy firmware (#3211) * Use voluptuous for free mobile (#3236) * Use voluptuous for nma (#3241) * Improve 1-Wire device family detection and error checking. Use volupt… (#3233) * Improve 1-Wire device family detection and error checking. Use voluptuous * Fix detection of gpio connected devices * Replace rollershutter and garage door with cover, add fan (#3242) * Use voluptuous for Alarm.com (#3229) * Use voluptuous for gntp (#3237) * Use voluptuous for pushbullet, pushetta and pushover (#3240) * Migrate to voluptuous (#3230) 🐬 * Fix mFi sensors in uninitialized state (#3246) If mFi sensors are identified but not fully assigned they can have no tag value, and mficlient throws a ValueError to signal this. This patch handles that case by considering such devices to always be STATE_OFF. * Use voluptuous for PulseAudio Loopback (#3160) * Migrate to voluptuous * Fix conf var * Use voluptuous for Verisure (#3169) * Migrate to voluptuous * Update type and add missing config variable * thread safe modbus (#3188) * Upgraded fitbit to version 0.2.3 which fixed oauthlib.oauth2.rfc6749.errors.TokenExpiredError: (token_expired) (#3244) * update ffmpeg version to 0.10 add get image to camera (#3235) * Migrate to voluptuous (#3234) * fix bugfix with unique_id (#3217) * Zwave climate fix and wink cover. (#3205) * Fixes setpoint get was done outside loop * zxt_120 * Wink not migrated to cover * Clarifying debug * too long line * Only add 1 device entity * Owntracks voluptuous fix (#3191) * Zwave set temperature fix (#3221) * If device was off set target temp would not work. * Changed to use a workaround just for Horstmann HRT4-ZW Zwave Thermostat * Wrong Horseman id * style changes * Change PR to suggestion on gitter (#3243) * Reload groups (#3203) * Allow reloading groups without restart * Test to make sure automation listeners are removed. * Remove unused imports for group tests * Simplify group config validation * Add prepare_reload function to entity component * Migrate group to use entity_component.prepare_reload * Migrate automation to use entity_component.prepare_reload * Clean up group.get_entity_ids * Use cv.boolean for group config validation * fix remove listener (#3196) * Add linux battery sensor (#3238) * protect service data for changes in calls (#3249) * protect service data for changes in calls * change handling * move MappingProxyType to service call * Fix issue #3250 (#3253) * Minor Ecobee changes (#3131) * Update configuration check, ordering, and constants * Make API key optional * issue #3250 * Add voluptuous to ecobee (#3257) * Use constants and update ordering (#3261) * Add support for complex template structures to data_template (#3255) * Improve yaml fault tolerance and handle check_config border cases (#3159) * Use voluptuous for nx584 alarm (#3231) * Migrate to voluptuous * Fix pylint issue * fastdotcom from pypi (#3269) * Use constants and update ordering (#3268) 🐬 * Use constants and update ordering (#3267) 🐬 * Add additional template for custom date formats (#3262) I can live with a few visual line breaks 🐬 * Use constants and update ordering (#3266) * Updated braviatv's braviarc version to 0.3.5 (#3271) * Use voluptuous for Device Sun Light Trigger (#3105) * Migrate to voluptuous * Use default * Point to master till archive is back (#3285) * Pi-Hole statistics sensor (#3158) * Add Pi-Hole sensor * Update docstrings and remove print() * Use None for payload * Added stuff for support range setting (#3189) * cleanup Homematic code (#3291) * cleanup old code * cleanup round 2 * remove unwanted platforms * Update frontend * Hotfix for #3100 (#3302) * Fix TP-Link Archer C7 long passwords (#3225) * Fix tplink C7 long passwords Fixes an issue where passwords longer than 15 chars could not log in to Archer C7 routers. * Truncate in correct place * Add comment about TP-Link C7 pass truncation * Fix lint error * Truncate comment at 79 chars not 80 * modbus write registers service (#3252) * Fix bloomsky platform discovery (#3303) * Remove dev tag --- .coveragerc | 5 + .gitignore | 5 +- docs/Makefile | 230 ++++++++++ docs/build/.empty | 0 docs/make.bat | 281 ++++++++++++ docs/source/_ext/edit_on_github.py | 45 ++ docs/source/_static/favicon.ico | Bin 0 -> 17957 bytes docs/source/_static/logo-apple.png | Bin 0 -> 15269 bytes docs/source/_static/logo.png | Bin 0 -> 15701 bytes docs/source/_templates/links.html | 8 + docs/source/_templates/sourcelink.html | 13 + docs/source/api/bootstrap.rst | 7 + docs/source/api/core.rst | 18 + docs/source/api/device_tracker.rst | 10 + docs/source/api/entity.rst | 12 + docs/source/api/event.rst | 20 + docs/source/api/helpers.rst | 118 +++++ docs/source/api/homeassistant.rst | 78 ++++ docs/source/api/util.rst | 78 ++++ docs/source/conf.py | 419 ++++++++++++++++++ docs/source/index.rst | 22 + homeassistant/bootstrap.py | 137 +++--- .../alarm_control_panel/alarmdotcom.py | 32 +- .../components/alarm_control_panel/mqtt.py | 40 +- .../components/alarm_control_panel/nx584.py | 32 +- .../alarm_control_panel/simplisafe.py | 31 +- .../alarm_control_panel/verisure.py | 6 +- homeassistant/components/api.py | 41 +- .../components/automation/__init__.py | 251 +++++++++-- homeassistant/components/automation/event.py | 3 +- homeassistant/components/automation/mqtt.py | 9 +- .../components/automation/numeric_state.py | 5 +- .../components/automation/services.yaml | 34 ++ homeassistant/components/automation/state.py | 36 +- homeassistant/components/automation/sun.py | 6 +- .../components/automation/template.py | 3 +- homeassistant/components/automation/time.py | 6 +- homeassistant/components/automation/zone.py | 6 +- .../components/binary_sensor/__init__.py | 1 + .../components/binary_sensor/bloomsky.py | 41 +- .../components/binary_sensor/command_line.py | 52 ++- .../components/binary_sensor/ecobee.py | 4 +- .../components/binary_sensor/ffmpeg.py | 2 +- .../components/binary_sensor/homematic.py | 44 +- .../components/binary_sensor/mqtt.py | 37 +- .../components/binary_sensor/nest.py | 16 +- .../components/binary_sensor/octoprint.py | 59 ++- .../components/binary_sensor/rest.py | 10 +- .../components/binary_sensor/template.py | 36 +- .../components/binary_sensor/trend.py | 145 ++++++ .../components/binary_sensor/zigbee.py | 21 +- homeassistant/components/bloomsky.py | 34 +- homeassistant/components/camera/ffmpeg.py | 24 +- homeassistant/components/climate/__init__.py | 61 ++- homeassistant/components/climate/demo.py | 38 +- homeassistant/components/climate/ecobee.py | 38 +- .../components/climate/eq3btsmart.py | 10 +- .../components/climate/generic_thermostat.py | 8 +- homeassistant/components/climate/heatmiser.py | 10 +- homeassistant/components/climate/homematic.py | 33 +- homeassistant/components/climate/honeywell.py | 13 +- homeassistant/components/climate/knx.py | 7 +- homeassistant/components/climate/nest.py | 13 +- homeassistant/components/climate/proliphix.py | 7 +- .../components/climate/radiotherm.py | 7 +- homeassistant/components/climate/zwave.py | 69 ++- .../components/cover/command_line.py | 53 ++- homeassistant/components/cover/demo.py | 27 +- homeassistant/components/cover/homematic.py | 25 +- homeassistant/components/demo.py | 4 +- .../components/device_sun_light_trigger.py | 35 +- .../components/device_tracker/__init__.py | 25 +- .../components/device_tracker/aruba.py | 29 +- .../components/device_tracker/asuswrt.py | 43 +- .../components/device_tracker/automatic.py | 161 +++++++ .../device_tracker/bluetooth_le_tracker.py | 14 +- .../device_tracker/bluetooth_tracker.py | 34 +- .../device_tracker/bt_home_hub_5.py | 17 +- .../components/device_tracker/ddwrt.py | 26 +- .../components/device_tracker/fritz.py | 47 +- .../components/device_tracker/luci.py | 26 +- .../components/device_tracker/mqtt.py | 3 +- .../components/device_tracker/netgear.py | 39 +- .../components/device_tracker/nmap_tracker.py | 17 +- .../components/device_tracker/owntracks.py | 71 ++- .../components/device_tracker/snmp.py | 17 +- .../components/device_tracker/tomato.py | 18 +- .../components/device_tracker/tplink.py | 6 +- .../components/device_tracker/ubus.py | 16 +- .../components/device_tracker/unifi.py | 35 +- homeassistant/components/downloader.py | 23 +- homeassistant/components/ecobee.py | 31 +- homeassistant/components/emulated_hue.py | 2 +- homeassistant/components/fan/__init__.py | 1 + homeassistant/components/fan/demo.py | 4 +- homeassistant/components/fan/mqtt.py | 276 ++++++++++++ homeassistant/components/feedreader.py | 43 +- homeassistant/components/foursquare.py | 61 +-- homeassistant/components/frontend/version.py | 2 +- .../components/frontend/www_static/core.js.gz | Bin 32161 -> 32161 bytes .../frontend/www_static/frontend.html | 4 +- .../frontend/www_static/frontend.html.gz | Bin 124432 -> 126732 bytes .../www_static/home-assistant-polymer | 2 +- .../panels/ha-panel-dev-event.html.gz | Bin 2639 -> 2639 bytes .../panels/ha-panel-dev-info.html.gz | Bin 1308 -> 1308 bytes .../panels/ha-panel-dev-service.html.gz | Bin 2824 -> 2824 bytes .../panels/ha-panel-dev-state.html.gz | Bin 2772 -> 2772 bytes .../panels/ha-panel-dev-template.html.gz | Bin 7290 -> 7290 bytes .../panels/ha-panel-history.html.gz | Bin 6842 -> 6842 bytes .../www_static/panels/ha-panel-iframe.html.gz | Bin 403 -> 403 bytes .../panels/ha-panel-logbook.html.gz | Bin 7344 -> 7344 bytes .../www_static/panels/ha-panel-map.html.gz | Bin 43920 -> 43920 bytes .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 2282 -> 2282 bytes .../www_static/webcomponents-lite.min.js.gz | Bin 12355 -> 12355 bytes homeassistant/components/group.py | 118 +++-- homeassistant/components/hdmi_cec.py | 33 +- homeassistant/components/homematic.py | 154 ++++--- homeassistant/components/http.py | 18 +- homeassistant/components/ifttt.py | 24 +- homeassistant/components/keyboard.py | 5 +- homeassistant/components/light/__init__.py | 1 + homeassistant/components/light/flux_led.py | 4 +- homeassistant/components/light/homematic.py | 45 +- homeassistant/components/light/hue.py | 13 + .../components/light/osramlightify.py | 14 +- homeassistant/components/light/services.yaml | 6 + homeassistant/components/light/zigbee.py | 21 +- homeassistant/components/lock/mqtt.py | 30 +- homeassistant/components/lock/verisure.py | 5 +- .../components/media_player/braviatv.py | 45 +- homeassistant/components/media_player/cast.py | 21 +- homeassistant/components/media_player/cmus.py | 48 +- .../components/media_player/denon.py | 70 +-- .../components/media_player/directv.py | 23 +- .../components/media_player/firetv.py | 74 ++-- .../components/media_player/gpmdp.py | 64 ++- homeassistant/components/media_player/kodi.py | 46 +- .../components/media_player/lg_netcast.py | 26 +- .../components/media_player/mpchc.py | 28 +- homeassistant/components/media_player/mpd.py | 43 +- .../components/media_player/onkyo.py | 58 ++- .../media_player/panasonic_viera.py | 41 +- .../components/media_player/pioneer.py | 31 +- homeassistant/components/media_player/roku.py | 12 +- .../components/media_player/samsungtv.py | 73 +-- .../components/media_player/snapcast.py | 42 +- .../components/media_player/sonos.py | 6 +- .../components/media_player/squeezebox.py | 50 +-- homeassistant/components/modbus.py | 105 ++++- homeassistant/components/mqtt/__init__.py | 7 +- homeassistant/components/nest.py | 19 +- homeassistant/components/netatmo.py | 6 +- homeassistant/components/notify/__init__.py | 21 +- homeassistant/components/notify/aws_lambda.py | 27 +- homeassistant/components/notify/aws_sns.py | 29 +- homeassistant/components/notify/aws_sqs.py | 25 +- .../components/notify/command_line.py | 20 +- homeassistant/components/notify/ecobee.py | 16 +- homeassistant/components/notify/file.py | 31 +- .../components/notify/free_mobile.py | 19 +- homeassistant/components/notify/gntp.py | 40 +- homeassistant/components/notify/group.py | 7 +- homeassistant/components/notify/html5.py | 6 +- homeassistant/components/notify/instapush.py | 37 +- .../components/notify/joaoapps_join.py | 11 +- .../components/notify/message_bird.py | 37 +- homeassistant/components/notify/nma.py | 17 +- homeassistant/components/notify/pushbullet.py | 17 +- homeassistant/components/notify/pushetta.py | 29 +- homeassistant/components/notify/pushover.py | 11 +- homeassistant/components/notify/rest.py | 6 +- homeassistant/components/notify/sendgrid.py | 32 +- homeassistant/components/notify/slack.py | 38 +- homeassistant/components/notify/smtp.py | 42 +- homeassistant/components/notify/syslog.py | 109 +++-- homeassistant/components/notify/telegram.py | 43 +- homeassistant/components/notify/twilio_sms.py | 20 +- homeassistant/components/notify/twitter.py | 21 +- homeassistant/components/notify/webostv.py | 27 +- homeassistant/components/notify/xmpp.py | 27 +- homeassistant/components/octoprint.py | 36 +- homeassistant/components/pilight.py | 52 +-- homeassistant/components/recorder/models.py | 3 +- .../components/rollershutter/demo.py | 15 +- .../components/rollershutter/homematic.py | 102 ----- homeassistant/components/sensor/bitcoin.py | 27 +- homeassistant/components/sensor/bloomsky.py | 62 +-- .../components/sensor/coinmarketcap.py | 125 ++++++ .../components/sensor/command_line.py | 36 +- homeassistant/components/sensor/dht.py | 48 +- homeassistant/components/sensor/eliqonline.py | 34 +- homeassistant/components/sensor/fastdotcom.py | 3 +- homeassistant/components/sensor/fitbit.py | 2 +- homeassistant/components/sensor/forecast.py | 29 +- .../components/sensor/fritzbox_callmonitor.py | 28 +- homeassistant/components/sensor/gpsd.py | 2 +- homeassistant/components/sensor/gtfs.py | 177 ++++---- homeassistant/components/sensor/homematic.py | 45 +- homeassistant/components/sensor/lastfm.py | 18 +- .../components/sensor/linux_battery.py | 125 ++++++ homeassistant/components/sensor/mfi.py | 59 ++- homeassistant/components/sensor/modbus.py | 7 +- .../components/sensor/mold_indicator.py | 95 ++-- homeassistant/components/sensor/mqtt.py | 17 +- homeassistant/components/sensor/mqtt_room.py | 43 +- homeassistant/components/sensor/octoprint.py | 62 ++- homeassistant/components/sensor/onewire.py | 65 +-- .../components/sensor/openweathermap.py | 58 +-- homeassistant/components/sensor/pi_hole.py | 101 +++++ homeassistant/components/sensor/rest.py | 12 +- homeassistant/components/sensor/rfxtrx.py | 22 +- homeassistant/components/sensor/sabnzbd.py | 18 +- .../components/sensor/steam_online.py | 16 +- .../components/sensor/supervisord.py | 21 +- .../components/sensor/systemmonitor.py | 7 +- homeassistant/components/sensor/temper.py | 46 +- homeassistant/components/sensor/template.py | 11 +- homeassistant/components/sensor/torque.py | 38 +- homeassistant/components/sensor/uber.py | 187 ++++---- homeassistant/components/sensor/verisure.py | 19 +- homeassistant/components/sensor/xbox_live.py | 112 +++++ homeassistant/components/sensor/zigbee.py | 31 +- homeassistant/components/services.yaml | 5 + .../components/switch/acer_projector.py | 88 ++-- .../components/switch/command_line.py | 49 +- homeassistant/components/switch/dlink.py | 36 +- homeassistant/components/switch/edimax.py | 37 +- homeassistant/components/switch/flux.py | 14 +- .../components/switch/hikvisioncam.py | 49 +- homeassistant/components/switch/homematic.py | 51 +-- homeassistant/components/switch/mfi.py | 46 +- homeassistant/components/switch/modbus.py | 21 +- homeassistant/components/switch/mqtt.py | 46 +- homeassistant/components/switch/netio.py | 56 +-- homeassistant/components/switch/orvibo.py | 50 ++- .../components/switch/pulseaudio_loopback.py | 119 ++--- homeassistant/components/switch/template.py | 20 +- homeassistant/components/switch/tplink.py | 28 +- homeassistant/components/switch/verisure.py | 5 +- homeassistant/components/switch/zigbee.py | 23 +- .../components/thermostat/homematic.py | 90 ---- homeassistant/components/verisure.py | 38 +- homeassistant/components/weblink.py | 37 +- homeassistant/components/wink.py | 4 +- homeassistant/components/zigbee.py | 44 +- homeassistant/components/zone.py | 41 +- homeassistant/const.py | 61 ++- homeassistant/core.py | 29 +- homeassistant/helpers/config_validation.py | 29 +- homeassistant/helpers/entity.py | 4 + homeassistant/helpers/entity_component.py | 85 +++- homeassistant/helpers/event.py | 66 +-- homeassistant/helpers/script.py | 15 +- homeassistant/helpers/service.py | 14 +- homeassistant/helpers/template.py | 21 +- homeassistant/remote.py | 11 +- homeassistant/scripts/check_config.py | 8 +- homeassistant/util/yaml.py | 10 + requirements_all.txt | 51 ++- requirements_docs.txt | 3 + setup.cfg | 3 + setup.py | 32 +- tests/common.py | 12 +- tests/components/automation/test_event.py | 7 + tests/components/automation/test_init.py | 211 ++++++++- tests/components/automation/test_mqtt.py | 6 + .../automation/test_numeric_state.py | 8 + tests/components/automation/test_state.py | 6 + tests/components/automation/test_sun.py | 12 + tests/components/automation/test_template.py | 33 +- tests/components/automation/test_time.py | 6 + tests/components/automation/test_zone.py | 18 + .../binary_sensor/test_command_line.py | 23 +- tests/components/binary_sensor/test_trend.py | 229 ++++++++++ tests/components/camera/test_local_file.py | 2 +- tests/components/climate/test_demo.py | 64 ++- tests/components/climate/test_honeywell.py | 8 +- tests/components/cover/test_command_line.py | 16 +- .../components/device_tracker/test_asuswrt.py | 2 +- .../device_tracker/test_automatic.py | 254 +++++++++++ .../device_tracker/test_owntracks.py | 127 +++++- tests/components/device_tracker/test_unifi.py | 60 ++- tests/components/mqtt/test_init.py | 9 +- tests/components/notify/test_command_line.py | 22 +- tests/components/notify/test_demo.py | 35 +- tests/components/notify/test_file.py | 7 +- tests/components/notify/test_smtp.py | 2 +- tests/components/sensor/test_command_line.py | 26 +- tests/components/sensor/test_mfi.py | 47 +- tests/components/sensor/test_moldindicator.py | 13 +- tests/components/switch/test_command_line.py | 22 +- tests/components/switch/test_mfi.py | 4 +- tests/components/test_group.py | 31 ++ tests/components/test_weblink.py | 14 +- tests/helpers/test_config_validation.py | 42 +- tests/helpers/test_event.py | 77 +++- tests/helpers/test_service.py | 8 + tests/helpers/test_template.py | 24 + tests/scripts/test_check_config.py | 9 +- tests/test_bootstrap.py | 11 +- tests/test_core.py | 29 +- tests/util/test_yaml.py | 111 ++--- 303 files changed, 8104 insertions(+), 3318 deletions(-) create mode 100644 docs/Makefile create mode 100644 docs/build/.empty create mode 100644 docs/make.bat create mode 100644 docs/source/_ext/edit_on_github.py create mode 100644 docs/source/_static/favicon.ico create mode 100644 docs/source/_static/logo-apple.png create mode 100644 docs/source/_static/logo.png create mode 100644 docs/source/_templates/links.html create mode 100644 docs/source/_templates/sourcelink.html create mode 100644 docs/source/api/bootstrap.rst create mode 100644 docs/source/api/core.rst create mode 100644 docs/source/api/device_tracker.rst create mode 100644 docs/source/api/entity.rst create mode 100644 docs/source/api/event.rst create mode 100644 docs/source/api/helpers.rst create mode 100644 docs/source/api/homeassistant.rst create mode 100644 docs/source/api/util.rst create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst create mode 100644 homeassistant/components/automation/services.yaml create mode 100644 homeassistant/components/binary_sensor/trend.py create mode 100644 homeassistant/components/device_tracker/automatic.py create mode 100644 homeassistant/components/fan/mqtt.py delete mode 100644 homeassistant/components/rollershutter/homematic.py create mode 100644 homeassistant/components/sensor/coinmarketcap.py create mode 100644 homeassistant/components/sensor/linux_battery.py create mode 100644 homeassistant/components/sensor/pi_hole.py create mode 100644 homeassistant/components/sensor/xbox_live.py delete mode 100644 homeassistant/components/thermostat/homematic.py create mode 100644 requirements_docs.txt create mode 100644 tests/components/binary_sensor/test_trend.py create mode 100644 tests/components/device_tracker/test_automatic.py diff --git a/.coveragerc b/.coveragerc index 48ea0375587..ff540ca1f2e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -138,6 +138,7 @@ omit = homeassistant/components/device_tracker/ubus.py homeassistant/components/discovery.py homeassistant/components/downloader.py + homeassistant/components/fan/mqtt.py homeassistant/components/feedreader.py homeassistant/components/foursquare.py homeassistant/components/garage_door/rpi_gpio.py @@ -205,6 +206,7 @@ omit = homeassistant/components/scene/hunterdouglas_powerview.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/bitcoin.py + homeassistant/components/sensor/coinmarketcap.py homeassistant/components/sensor/cpuspeed.py homeassistant/components/sensor/deutsche_bahn.py homeassistant/components/sensor/dht.py @@ -223,6 +225,7 @@ omit = homeassistant/components/sensor/hp_ilo.py homeassistant/components/sensor/imap.py homeassistant/components/sensor/lastfm.py + homeassistant/components/sensor/linux_battery.py homeassistant/components/sensor/loopenergy.py homeassistant/components/sensor/mhz19.py homeassistant/components/sensor/mqtt_room.py @@ -232,6 +235,7 @@ omit = homeassistant/components/sensor/onewire.py homeassistant/components/sensor/openexchangerates.py homeassistant/components/sensor/openweathermap.py + homeassistant/components/sensor/pi_hole.py homeassistant/components/sensor/plex.py homeassistant/components/sensor/rest.py homeassistant/components/sensor/sabnzbd.py @@ -250,6 +254,7 @@ omit = homeassistant/components/sensor/twitch.py homeassistant/components/sensor/uber.py homeassistant/components/sensor/worldclock.py + homeassistant/components/sensor/xbox_live.py homeassistant/components/sensor/yweather.py homeassistant/components/switch/acer_projector.py homeassistant/components/switch/arest.py diff --git a/.gitignore b/.gitignore index b73dcef1073..147d68c36d3 100644 --- a/.gitignore +++ b/.gitignore @@ -96,4 +96,7 @@ virtualization/vagrant/.vagrant virtualization/vagrant/config # Visual Studio Code -.vscode \ No newline at end of file +.vscode + +# Built docs +docs/build \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000000..69893c43847 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,230 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source + +.PHONY: help +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " livehtml to make standalone HTML files via sphinx-autobuild" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " applehelp to make an Apple Help Book" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " epub3 to make an epub3" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + @echo " dummy to check syntax errors of document sources" + +.PHONY: clean +clean: + rm -rf $(BUILDDIR)/* + +.PHONY: html +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +.PHONY: livehtml +livehtml: + sphinx-autobuild -z ../homeassistant/ --port 0 -B -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + +.PHONY: dirhtml +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +.PHONY: singlehtml +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +.PHONY: pickle +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +.PHONY: json +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +.PHONY: htmlhelp +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +.PHONY: qthelp +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Home-Assistant.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Home-Assistant.qhc" + +.PHONY: applehelp +applehelp: + $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp + @echo + @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." + @echo "N.B. You won't be able to view it unless you put it in" \ + "~/Library/Documentation/Help or install it in your application" \ + "bundle." + +.PHONY: devhelp +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/Home-Assistant" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Home-Assistant" + @echo "# devhelp" + +.PHONY: epub +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +.PHONY: epub3 +epub3: + $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 + @echo + @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." + +.PHONY: latex +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +.PHONY: latexpdf +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: latexpdfja +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: text +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +.PHONY: man +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +.PHONY: texinfo +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +.PHONY: info +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +.PHONY: gettext +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +.PHONY: changes +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +.PHONY: linkcheck +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +.PHONY: doctest +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +.PHONY: coverage +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +.PHONY: xml +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +.PHONY: pseudoxml +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." + +.PHONY: dummy +dummy: + $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy + @echo + @echo "Build finished. Dummy builder generates no files." diff --git a/docs/build/.empty b/docs/build/.empty new file mode 100644 index 00000000000..e69de29bb2d diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000000..7713f1cadb0 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,281 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source +set I18NSPHINXOPTS=%SPHINXOPTS% source +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. epub3 to make an epub3 + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + echo. coverage to run coverage check of the documentation if enabled + echo. dummy to check syntax errors of document sources + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +REM Check if sphinx-build is available and fallback to Python version if any +%SPHINXBUILD% 1>NUL 2>NUL +if errorlevel 9009 goto sphinx_python +goto sphinx_ok + +:sphinx_python + +set SPHINXBUILD=python -m sphinx.__init__ +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +:sphinx_ok + + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Home-Assistant.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Home-Assistant.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "epub3" ( + %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "coverage" ( + %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage + if errorlevel 1 exit /b 1 + echo. + echo.Testing of coverage in the sources finished, look at the ^ +results in %BUILDDIR%/coverage/python.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +if "%1" == "dummy" ( + %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. Dummy builder generates no files. + goto end +) + +:end diff --git a/docs/source/_ext/edit_on_github.py b/docs/source/_ext/edit_on_github.py new file mode 100644 index 00000000000..eef249a3f01 --- /dev/null +++ b/docs/source/_ext/edit_on_github.py @@ -0,0 +1,45 @@ +""" +Sphinx extension to add ReadTheDocs-style "Edit on GitHub" links to the +sidebar. + +Loosely based on https://github.com/astropy/astropy/pull/347 +""" + +import os +import warnings + + +__licence__ = 'BSD (3 clause)' + + +def get_github_url(app, view, path): + github_fmt = 'https://github.com/{}/{}/{}/{}{}' + return ( + github_fmt.format(app.config.edit_on_github_project, view, + app.config.edit_on_github_branch, + app.config.edit_on_github_src_path, path)) + + +def html_page_context(app, pagename, templatename, context, doctree): + if templatename != 'page.html': + return + + if not app.config.edit_on_github_project: + warnings.warn("edit_on_github_project not specified") + return + if not doctree: + warnings.warn("doctree is None") + return + path = os.path.relpath(doctree.get('source'), app.builder.srcdir) + show_url = get_github_url(app, 'blob', path) + edit_url = get_github_url(app, 'edit', path) + + context['show_on_github_url'] = show_url + context['edit_on_github_url'] = edit_url + + +def setup(app): + app.add_config_value('edit_on_github_project', '', True) + app.add_config_value('edit_on_github_branch', 'master', True) + app.add_config_value('edit_on_github_src_path', '', True) # 'eg' "docs/" + app.connect('html-page-context', html_page_context) diff --git a/docs/source/_static/favicon.ico b/docs/source/_static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..6d12158c18b17464323bea3d769003e6dd915af3 GIT binary patch literal 17957 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 literal 0 HcmV?d00001 diff --git a/docs/source/_static/logo-apple.png b/docs/source/_static/logo-apple.png new file mode 100644 index 0000000000000000000000000000000000000000..20117d00f22756f4e77b60042ac48186e31a3861 GIT binary patch literal 15269 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 literal 0 HcmV?d00001 diff --git a/docs/source/_templates/links.html b/docs/source/_templates/links.html new file mode 100644 index 00000000000..57a2e09f99e --- /dev/null +++ b/docs/source/_templates/links.html @@ -0,0 +1,8 @@ + +
diff --git a/docs/source/_templates/sourcelink.html b/docs/source/_templates/sourcelink.html new file mode 100644 index 00000000000..8cf2c4f92ae --- /dev/null +++ b/docs/source/_templates/sourcelink.html @@ -0,0 +1,13 @@ +{%- if show_source and has_source and sourcename %} +

{{ _('This Page') }}

+ +{%- endif %} diff --git a/docs/source/api/bootstrap.rst b/docs/source/api/bootstrap.rst new file mode 100644 index 00000000000..363f7969961 --- /dev/null +++ b/docs/source/api/bootstrap.rst @@ -0,0 +1,7 @@ +.. _bootstrap_module: + +:mod:`homeassistant.bootstrap` +------------------------- + +.. automodule:: homeassistant.bootstrap + :members: diff --git a/docs/source/api/core.rst b/docs/source/api/core.rst new file mode 100644 index 00000000000..a32bdc24d11 --- /dev/null +++ b/docs/source/api/core.rst @@ -0,0 +1,18 @@ +.. _core_module: + +:mod:`homeassistant.core` +------------------------- + +.. automodule:: homeassistant.core + +.. autoclass:: Config + :members: + +.. autoclass:: EventBus + :members: + +.. autoclass:: StateMachine + :members: + +.. autoclass:: ServiceRegistry + :members: diff --git a/docs/source/api/device_tracker.rst b/docs/source/api/device_tracker.rst new file mode 100644 index 00000000000..e3d65174815 --- /dev/null +++ b/docs/source/api/device_tracker.rst @@ -0,0 +1,10 @@ +.. _components_device_tracker_module: + +:mod:`homeassistant.components.device_tracker` +---------------------------------------------- + +.. automodule:: homeassistant.components.device_tracker + :members: + +.. autoclass:: Device + :members: diff --git a/docs/source/api/entity.rst b/docs/source/api/entity.rst new file mode 100644 index 00000000000..99ae43dc3ae --- /dev/null +++ b/docs/source/api/entity.rst @@ -0,0 +1,12 @@ +.. _helpers_entity_module: + +:mod:`homeassistant.helpers.entity` +----------------------------------- + +.. automodule:: homeassistant.helpers.entity + +.. autoclass:: Entity + :members: + +.. autoclass:: ToggleEntity + :members: diff --git a/docs/source/api/event.rst b/docs/source/api/event.rst new file mode 100644 index 00000000000..b1295b81409 --- /dev/null +++ b/docs/source/api/event.rst @@ -0,0 +1,20 @@ +.. _helpers_event_module: + +:mod:`homeassistant.helpers.event` +---------------------------------- + +.. automodule:: homeassistant.helpers.event + +.. autofunction:: track_state_change + +.. autofunction:: track_point_in_time + +.. autofunction:: track_point_in_utc_time + +.. autofunction:: track_sunrise + +.. autofunction:: track_sunset + +.. autofunction:: track_utc_time_change + +.. autofunction:: track_time_change diff --git a/docs/source/api/helpers.rst b/docs/source/api/helpers.rst new file mode 100644 index 00000000000..af186fb1341 --- /dev/null +++ b/docs/source/api/helpers.rst @@ -0,0 +1,118 @@ +homeassistant.helpers package +============================= + +Submodules +---------- + +homeassistant.helpers.condition module +-------------------------------------- + +.. automodule:: homeassistant.helpers.condition + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.config_validation module +---------------------------------------------- + +.. automodule:: homeassistant.helpers.config_validation + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.discovery module +-------------------------------------- + +.. automodule:: homeassistant.helpers.discovery + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.entity module +----------------------------------- + +.. automodule:: homeassistant.helpers.entity + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.entity_component module +--------------------------------------------- + +.. automodule:: homeassistant.helpers.entity_component + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.event module +---------------------------------- + +.. automodule:: homeassistant.helpers.event + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.event_decorators module +--------------------------------------------- + +.. automodule:: homeassistant.helpers.event_decorators + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.location module +------------------------------------- + +.. automodule:: homeassistant.helpers.location + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.script module +----------------------------------- + +.. automodule:: homeassistant.helpers.script + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.service module +------------------------------------ + +.. automodule:: homeassistant.helpers.service + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.state module +---------------------------------- + +.. automodule:: homeassistant.helpers.state + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.template module +------------------------------------- + +.. automodule:: homeassistant.helpers.template + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.typing module +----------------------------------- + +.. automodule:: homeassistant.helpers.typing + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: homeassistant.helpers + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/homeassistant.rst b/docs/source/api/homeassistant.rst new file mode 100644 index 00000000000..f5ff069451d --- /dev/null +++ b/docs/source/api/homeassistant.rst @@ -0,0 +1,78 @@ +homeassistant package +===================== + +Subpackages +----------- + +.. toctree:: + + helpers + util + +Submodules +---------- + +bootstrap module +------------------------------ + +.. automodule:: homeassistant.bootstrap + :members: + :undoc-members: + :show-inheritance: + +config module +--------------------------- + +.. automodule:: homeassistant.config + :members: + :undoc-members: + :show-inheritance: + +const module +-------------------------- + +.. automodule:: homeassistant.const + :members: + :undoc-members: + :show-inheritance: + +core module +------------------------- + +.. automodule:: homeassistant.core + :members: + :undoc-members: + :show-inheritance: + +exceptions module +------------------------------- + +.. automodule:: homeassistant.exceptions + :members: + :undoc-members: + :show-inheritance: + +loader module +--------------------------- + +.. automodule:: homeassistant.loader + :members: + :undoc-members: + :show-inheritance: + +remote module +--------------------------- + +.. automodule:: homeassistant.remote + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: homeassistant + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/util.rst b/docs/source/api/util.rst new file mode 100644 index 00000000000..7d6a22dbc0b --- /dev/null +++ b/docs/source/api/util.rst @@ -0,0 +1,78 @@ +homeassistant.util package +========================== + +Submodules +---------- + +homeassistant.util.color module +------------------------------- + +.. automodule:: homeassistant.util.color + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.distance module +---------------------------------- + +.. automodule:: homeassistant.util.distance + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.dt module +---------------------------- + +.. automodule:: homeassistant.util.dt + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.location module +---------------------------------- + +.. automodule:: homeassistant.util.location + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.package module +--------------------------------- + +.. automodule:: homeassistant.util.package + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.temperature module +------------------------------------- + +.. automodule:: homeassistant.util.temperature + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.unit_system module +------------------------------------- + +.. automodule:: homeassistant.util.unit_system + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.yaml module +------------------------------ + +.. automodule:: homeassistant.util.yaml + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: homeassistant.util + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 00000000000..18b14795caa --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# Home-Assistant documentation build configuration file, created by +# sphinx-quickstart on Sun Aug 28 13:13:10 2016. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import sys +import os +from os.path import relpath +import inspect +from homeassistant.const import (__version__, __short_version__, PROJECT_NAME, + PROJECT_LONG_DESCRIPTION, + PROJECT_COPYRIGHT, PROJECT_AUTHOR, + PROJECT_GITHUB_USERNAME, + PROJECT_GITHUB_REPOSITORY, + GITHUB_PATH, GITHUB_URL) + + +sys.path.insert(0, os.path.abspath('_ext')) +sys.path.insert(0, os.path.abspath('../homeassistant')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.linkcode', + 'sphinx_autodoc_annotation', + 'edit_on_github' +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The encoding of source files. +# +# source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = PROJECT_NAME +copyright = PROJECT_COPYRIGHT +author = PROJECT_AUTHOR + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = __short_version__ +# The full version, including alpha/beta/rc tags. +release = __version__ + +code_branch = 'dev' if 'dev' in __version__ else 'master' + +# Edit on Github config +edit_on_github_project = GITHUB_PATH +edit_on_github_branch = code_branch +edit_on_github_src_path = 'docs/source/' + + +def linkcode_resolve(domain, info): + """ + Determine the URL corresponding to Python object + """ + if domain != 'py': + return None + modname = info['module'] + fullname = info['fullname'] + submod = sys.modules.get(modname) + if submod is None: + return None + obj = submod + for part in fullname.split('.'): + try: + obj = getattr(obj, part) + except: + return None + try: + fn = inspect.getsourcefile(obj) + except: + fn = None + if not fn: + return None + try: + source, lineno = inspect.findsource(obj) + except: + lineno = None + if lineno: + linespec = "#L%d" % (lineno + 1) + else: + linespec = "" + fn = relpath(fn, start='../') + + return '{}/blob/{}/{}{}'.format(GITHUB_URL, code_branch, fn, linespec) + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# +# today = '' +# +# Else, today_fmt is used as the format for a strftime call. +# +# today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = [] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +# +# default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +# +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# +# add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# +# show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +# modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +# keep_warnings = False + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +html_theme_options = { + 'logo': 'logo.png', + 'logo_name': PROJECT_NAME, + 'description': PROJECT_LONG_DESCRIPTION, + 'github_user': PROJECT_GITHUB_USERNAME, + 'github_repo': PROJECT_GITHUB_REPOSITORY, + 'github_type': 'star', + 'github_banner': True, + 'travis_button': True, + 'touch_icon': 'logo-apple.png', + # 'fixed_sidebar': True, # Re-enable when we have more content +} + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. +# " v documentation" by default. +# +# html_title = 'Home-Assistant v0.27.0' + +# A shorter title for the navigation bar. Default is the same as html_title. +# +# html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +# +# html_logo = '_static/logo.png' + +# The name of an image file (relative to this directory) to use as a favicon of +# the docs. +# This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +# +html_favicon = '_static/favicon.ico' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +# +# html_extra_path = [] + +# If not None, a 'Last updated on:' timestamp is inserted at every page +# bottom, using the given strftime format. +# The empty string is equivalent to '%b %d, %Y'. +# +html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# +html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# +html_sidebars = { + '**': [ + 'about.html', + 'links.html', + 'searchbox.html', + 'sourcelink.html', + 'navigation.html', + 'relations.html' + ] +} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# +# html_additional_pages = {} + +# If false, no module index is generated. +# +# html_domain_indices = True + +# If false, no index is generated. +# +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# +# html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +# +# html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +# +# html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# +# html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' +# +# html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# 'ja' uses this config value. +# 'zh' user can custom change `jieba` dictionary path. +# +# html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +# +# html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'Home-Assistantdoc' + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'Home-Assistant.tex', 'Home-Assistant Documentation', + 'Home-Assistant Team', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# +# latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# +# latex_use_parts = False + +# If true, show page references after internal links. +# +# latex_show_pagerefs = False + +# If true, show URL addresses after external links. +# +# latex_show_urls = False + +# Documents to append as an appendix to all manuals. +# +# latex_appendices = [] + +# It false, will not define \strong, \code, itleref, \crossref ... but only +# \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added +# packages. +# +# latex_keep_old_macro_names = True + +# If false, no module index is generated. +# +# latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'home-assistant', 'Home-Assistant Documentation', + [author], 1) +] + +# If true, show URL addresses after external links. +# +# man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'Home-Assistant', 'Home-Assistant Documentation', + author, 'Home-Assistant', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +# +# texinfo_appendices = [] + +# If false, no module index is generated. +# +# texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# +# texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +# +# texinfo_no_detailmenu = False diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 00000000000..a6157dc7aac --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,22 @@ +================================ +Home Assistant API Documentation +================================ + +Public API documentation for `Home Assistant developers`_. + +Contents: + +.. toctree:: + :maxdepth: 2 + :glob: + + api/* + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + +.. _Home Assistant developers: https://home-assistant.io/developers/ diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 4b526c40b38..5e291e90717 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -14,7 +14,7 @@ import voluptuous as vol from voluptuous.humanize import humanize_error import homeassistant.components as core_components -from homeassistant.components import group, persistent_notification +from homeassistant.components import persistent_notification import homeassistant.config as conf_util import homeassistant.core as core import homeassistant.loader as loader @@ -90,67 +90,12 @@ def _setup_component(hass: core.HomeAssistant, domain: str, config) -> bool: domain, domain) return False + config = prepare_setup_component(hass, config, domain) + + if config is None: + return False + component = loader.get_component(domain) - missing_deps = [dep for dep in getattr(component, 'DEPENDENCIES', []) - if dep not in hass.config.components] - - if missing_deps: - _LOGGER.error( - 'Not initializing %s because not all dependencies loaded: %s', - domain, ", ".join(missing_deps)) - return False - - if hasattr(component, 'CONFIG_SCHEMA'): - try: - config = component.CONFIG_SCHEMA(config) - except vol.MultipleInvalid as ex: - log_exception(ex, domain, config) - return False - - elif hasattr(component, 'PLATFORM_SCHEMA'): - platforms = [] - for p_name, p_config in config_per_platform(config, domain): - # Validate component specific platform schema - try: - p_validated = component.PLATFORM_SCHEMA(p_config) - except vol.MultipleInvalid as ex: - log_exception(ex, domain, p_config) - return False - - # Not all platform components follow same pattern for platforms - # So if p_name is None we are not going to validate platform - # (the automation component is one of them) - if p_name is None: - platforms.append(p_validated) - continue - - platform = prepare_setup_platform(hass, config, domain, - p_name) - - if platform is None: - return False - - # Validate platform specific schema - if hasattr(platform, 'PLATFORM_SCHEMA'): - try: - p_validated = platform.PLATFORM_SCHEMA(p_validated) - except vol.MultipleInvalid as ex: - log_exception(ex, '{}.{}'.format(domain, p_name), - p_validated) - return False - - platforms.append(p_validated) - - # Create a copy of the configuration with all config for current - # component removed and add validated config back in. - filter_keys = extract_domain_configs(config, domain) - config = {key: value for key, value in config.items() - if key not in filter_keys} - config[domain] = platforms - - if not _handle_requirements(hass, component, domain): - return False - _CURRENT_SETUP.append(domain) try: @@ -173,7 +118,7 @@ def _setup_component(hass: core.HomeAssistant, domain: str, config) -> bool: # Assumption: if a component does not depend on groups # it communicates with devices - if group.DOMAIN not in getattr(component, 'DEPENDENCIES', []): + if 'group' not in getattr(component, 'DEPENDENCIES', []): hass.pool.add_worker() hass.bus.fire( @@ -182,6 +127,74 @@ def _setup_component(hass: core.HomeAssistant, domain: str, config) -> bool: return True +def prepare_setup_component(hass: core.HomeAssistant, config: dict, + domain: str): + """Prepare setup of a component and return processed config.""" + # pylint: disable=too-many-return-statements + component = loader.get_component(domain) + missing_deps = [dep for dep in getattr(component, 'DEPENDENCIES', []) + if dep not in hass.config.components] + + if missing_deps: + _LOGGER.error( + 'Not initializing %s because not all dependencies loaded: %s', + domain, ", ".join(missing_deps)) + return None + + if hasattr(component, 'CONFIG_SCHEMA'): + try: + config = component.CONFIG_SCHEMA(config) + except vol.MultipleInvalid as ex: + log_exception(ex, domain, config) + return None + + elif hasattr(component, 'PLATFORM_SCHEMA'): + platforms = [] + for p_name, p_config in config_per_platform(config, domain): + # Validate component specific platform schema + try: + p_validated = component.PLATFORM_SCHEMA(p_config) + except vol.MultipleInvalid as ex: + log_exception(ex, domain, p_config) + return None + + # Not all platform components follow same pattern for platforms + # So if p_name is None we are not going to validate platform + # (the automation component is one of them) + if p_name is None: + platforms.append(p_validated) + continue + + platform = prepare_setup_platform(hass, config, domain, + p_name) + + if platform is None: + return None + + # Validate platform specific schema + if hasattr(platform, 'PLATFORM_SCHEMA'): + try: + p_validated = platform.PLATFORM_SCHEMA(p_validated) + except vol.MultipleInvalid as ex: + log_exception(ex, '{}.{}'.format(domain, p_name), + p_validated) + return None + + platforms.append(p_validated) + + # Create a copy of the configuration with all config for current + # component removed and add validated config back in. + filter_keys = extract_domain_configs(config, domain) + config = {key: value for key, value in config.items() + if key not in filter_keys} + config[domain] = platforms + + if not _handle_requirements(hass, component, domain): + return None + + return config + + def prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, platform_name: str) -> Optional[ModuleType]: """Load a platform and makes sure dependencies are setup.""" diff --git a/homeassistant/components/alarm_control_panel/alarmdotcom.py b/homeassistant/components/alarm_control_panel/alarmdotcom.py index 542cb5e3d02..a986d911115 100644 --- a/homeassistant/components/alarm_control_panel/alarmdotcom.py +++ b/homeassistant/components/alarm_control_panel/alarmdotcom.py @@ -6,34 +6,40 @@ https://home-assistant.io/components/alarm_control_panel.alarmdotcom/ """ import logging +import voluptuous as vol + import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_UNKNOWN) - -_LOGGER = logging.getLogger(__name__) - + STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_UNKNOWN, CONF_CODE, + CONF_NAME) +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['https://github.com/Xorso/pyalarmdotcom' '/archive/0.1.1.zip' '#pyalarmdotcom==0.1.1'] + +_LOGGER = logging.getLogger(__name__) + DEFAULT_NAME = 'Alarm.com' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_CODE): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup an Alarm.com control panel.""" + name = config.get(CONF_NAME) + code = config.get(CONF_CODE) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - if username is None or password is None: - _LOGGER.error('Must specify username and password!') - return False - - add_devices([AlarmDotCom(hass, - config.get('name', DEFAULT_NAME), - config.get('code'), - username, - password)]) + add_devices([AlarmDotCom(hass, name, code, username, password)]) # pylint: disable=too-many-arguments, too-many-instance-attributes diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py index 3bc7b860869..b5bdf478add 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -13,33 +13,31 @@ import homeassistant.components.mqtt as mqtt from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_UNKNOWN, - CONF_NAME) + CONF_NAME, CONF_CODE) from homeassistant.components.mqtt import ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ['mqtt'] - CONF_PAYLOAD_DISARM = 'payload_disarm' CONF_PAYLOAD_ARM_HOME = 'payload_arm_home' CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away' -CONF_CODE = 'code' -DEFAULT_NAME = "MQTT Alarm" -DEFAULT_DISARM = "DISARM" -DEFAULT_ARM_HOME = "ARM_HOME" -DEFAULT_ARM_AWAY = "ARM_AWAY" +DEFAULT_ARM_AWAY = 'ARM_AWAY' +DEFAULT_ARM_HOME = 'ARM_HOME' +DEFAULT_DISARM = 'DISARM' +DEFAULT_NAME = 'MQTT Alarm' +DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, - vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string, - vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string, - vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, + vol.Required(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_CODE): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + 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_DISARM, default=DEFAULT_DISARM): cv.string, }) @@ -47,20 +45,20 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the MQTT platform.""" add_devices([MqttAlarm( hass, - config[CONF_NAME], - config[CONF_STATE_TOPIC], - config[CONF_COMMAND_TOPIC], - config[CONF_QOS], - config[CONF_PAYLOAD_DISARM], - config[CONF_PAYLOAD_ARM_HOME], - config[CONF_PAYLOAD_ARM_AWAY], + config.get(CONF_NAME), + config.get(CONF_STATE_TOPIC), + config.get(CONF_COMMAND_TOPIC), + config.get(CONF_QOS), + config.get(CONF_PAYLOAD_DISARM), + config.get(CONF_PAYLOAD_ARM_HOME), + config.get(CONF_PAYLOAD_ARM_AWAY), config.get(CONF_CODE))]) # pylint: disable=too-many-arguments, too-many-instance-attributes # pylint: disable=abstract-method class MqttAlarm(alarm.AlarmControlPanel): - """Represent a MQTT alarm status.""" + """Representation of a MQTT alarm status.""" def __init__(self, hass, name, state_topic, command_topic, qos, payload_disarm, payload_arm_home, payload_arm_away, code): diff --git a/homeassistant/components/alarm_control_panel/nx584.py b/homeassistant/components/alarm_control_panel/nx584.py index 2b3facbdb0e..45857f3ef29 100644 --- a/homeassistant/components/alarm_control_panel/nx584.py +++ b/homeassistant/components/alarm_control_panel/nx584.py @@ -7,22 +7,40 @@ https://home-assistant.io/components/alarm_control_panel.nx584/ import logging import requests +import voluptuous as vol import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_UNKNOWN) + STATE_UNKNOWN, CONF_NAME, CONF_HOST, CONF_PORT) +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['pynx584==0.2'] + _LOGGER = logging.getLogger(__name__) +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'NX584' +DEFAULT_PORT = 5007 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup nx584 platform.""" - host = config.get('host', 'localhost:5007') + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + + url = 'http://{}:{}'.format(host, port) try: - add_devices([NX584Alarm(hass, host, config.get('name', 'NX584'))]) + add_devices([NX584Alarm(hass, url, name)]) except requests.exceptions.ConnectionError as ex: _LOGGER.error('Unable to connect to NX584: %s', str(ex)) return False @@ -31,13 +49,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class NX584Alarm(alarm.AlarmControlPanel): """Represents the NX584-based alarm panel.""" - def __init__(self, hass, host, name): + def __init__(self, hass, url, name): """Initalize the nx584 alarm panel.""" from nx584 import client self._hass = hass - self._host = host self._name = name - self._alarm = client.Client('http://%s' % host) + self._url = url + self._alarm = client.Client(self._url) # Do an initial list operation so that we will try to actually # talk to the API and trigger a requests exception for setup_platform() # to catch @@ -66,7 +84,7 @@ class NX584Alarm(alarm.AlarmControlPanel): zones = self._alarm.list_zones() except requests.exceptions.ConnectionError as ex: _LOGGER.error('Unable to connect to %(host)s: %(reason)s', - dict(host=self._host, reason=ex)) + dict(host=self._url, reason=ex)) return STATE_UNKNOWN except IndexError: _LOGGER.error('nx584 reports no partitions') diff --git a/homeassistant/components/alarm_control_panel/simplisafe.py b/homeassistant/components/alarm_control_panel/simplisafe.py index a248df5fc21..82927246ec6 100644 --- a/homeassistant/components/alarm_control_panel/simplisafe.py +++ b/homeassistant/components/alarm_control_panel/simplisafe.py @@ -6,32 +6,39 @@ https://home-assistant.io/components/alarm_control_panel.simplisafe/ """ import logging +import voluptuous as vol + import homeassistant.components.alarm_control_panel as alarm - +from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_PASSWORD, CONF_USERNAME, STATE_UNKNOWN, + CONF_PASSWORD, CONF_USERNAME, STATE_UNKNOWN, CONF_CODE, CONF_NAME, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY) +import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['https://github.com/w1ll1am23/simplisafe-python/archive/' '586fede0e85fd69e56e516aaa8e97eb644ca8866.zip#' 'simplisafe-python==0.0.1'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'SimpliSafe' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_CODE): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the SimpliSafe platform.""" + name = config.get(CONF_NAME) + code = config.get(CONF_CODE) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - if username is None or password is None: - _LOGGER.error('Must specify username and password!') - return False - - add_devices([SimpliSafeAlarm( - config.get('name', "SimpliSafe"), - username, - password, - config.get('code'))]) + add_devices([SimpliSafeAlarm(name, username, password, code)]) # pylint: disable=abstract-method diff --git a/homeassistant/components/alarm_control_panel/verisure.py b/homeassistant/components/alarm_control_panel/verisure.py index ee1ccfc1bd0..248d575baf7 100644 --- a/homeassistant/components/alarm_control_panel/verisure.py +++ b/homeassistant/components/alarm_control_panel/verisure.py @@ -8,7 +8,7 @@ import logging import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.verisure import HUB as hub - +from homeassistant.components.verisure import (CONF_ALARM, CONF_CODE_DIGITS) from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_UNKNOWN) @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Verisure platform.""" alarms = [] - if int(hub.config.get('alarm', '1')): + if int(hub.config.get(CONF_ALARM, 1)): hub.update_alarms() alarms.extend([ VerisureAlarm(value.id) @@ -36,7 +36,7 @@ class VerisureAlarm(alarm.AlarmControlPanel): """Initalize the Verisure alarm panel.""" self._id = device_id self._state = STATE_UNKNOWN - self._digits = int(hub.config.get('code_digits', '4')) + self._digits = hub.config.get(CONF_CODE_DIGITS) self._changed_by = None @property diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index f0073bad838..be455995743 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -98,31 +98,32 @@ class APIEventStream(HomeAssistantView): def stream(): """Stream events to response.""" - self.hass.bus.listen(MATCH_ALL, forward_events) + unsub_stream = self.hass.bus.listen(MATCH_ALL, forward_events) - _LOGGER.debug('STREAM %s ATTACHED', id(stop_obj)) + try: + _LOGGER.debug('STREAM %s ATTACHED', id(stop_obj)) - # Fire off one message right away to have browsers fire open event - to_write.put(STREAM_PING_PAYLOAD) + # Fire off one message so browsers fire open event right away + to_write.put(STREAM_PING_PAYLOAD) - while True: - try: - payload = to_write.get(timeout=STREAM_PING_INTERVAL) + while True: + try: + payload = to_write.get(timeout=STREAM_PING_INTERVAL) - if payload is stop_obj: + if payload is stop_obj: + break + + msg = "data: {}\n\n".format(payload) + _LOGGER.debug('STREAM %s WRITING %s', id(stop_obj), + msg.strip()) + yield msg.encode("UTF-8") + except queue.Empty: + to_write.put(STREAM_PING_PAYLOAD) + except GeneratorExit: break - - msg = "data: {}\n\n".format(payload) - _LOGGER.debug('STREAM %s WRITING %s', id(stop_obj), - msg.strip()) - yield msg.encode("UTF-8") - except queue.Empty: - to_write.put(STREAM_PING_PAYLOAD) - except GeneratorExit: - break - - _LOGGER.debug('STREAM %s RESPONSE CLOSED', id(stop_obj)) - self.hass.bus.remove_listener(MATCH_ALL, forward_events) + finally: + _LOGGER.debug('STREAM %s RESPONSE CLOSED', id(stop_obj)) + unsub_stream() return self.Response(stream(), mimetype='text/event-stream') diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index d99043f0c75..863d94033a8 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -4,19 +4,28 @@ Allow to setup simple automation rules via the config file. For more details about this component, please refer to the documentation at https://home-assistant.io/components/automation/ """ +from functools import partial import logging +import os import voluptuous as vol from homeassistant.bootstrap import prepare_setup_platform -from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM +from homeassistant import config as conf_util +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, + SERVICE_TOGGLE) from homeassistant.components import logbook from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import extract_domain_configs, script, condition +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.loader import get_platform +from homeassistant.util.dt import utcnow import homeassistant.helpers.config_validation as cv DOMAIN = 'automation' +ENTITY_ID_FORMAT = DOMAIN + '.{}' DEPENDENCIES = ['group'] @@ -36,6 +45,11 @@ DEFAULT_CONDITION_TYPE = CONDITION_TYPE_AND METHOD_TRIGGER = 'trigger' METHOD_IF_ACTION = 'if_action' +ATTR_LAST_TRIGGERED = 'last_triggered' +ATTR_VARIABLES = 'variables' +SERVICE_TRIGGER = 'trigger' +SERVICE_RELOAD = 'reload' + _LOGGER = logging.getLogger(__name__) @@ -88,41 +102,206 @@ PLATFORM_SCHEMA = vol.Schema({ vol.Required(CONF_TRIGGER): _TRIGGER_SCHEMA, vol.Required(CONF_CONDITION_TYPE, default=DEFAULT_CONDITION_TYPE): vol.All(vol.Lower, vol.Any(CONDITION_TYPE_AND, CONDITION_TYPE_OR)), - CONF_CONDITION: _CONDITION_SCHEMA, + vol.Optional(CONF_CONDITION): _CONDITION_SCHEMA, vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA, }) +SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +TRIGGER_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_VARIABLES, default={}): dict, +}) + +RELOAD_SERVICE_SCHEMA = vol.Schema({}) + + +def is_on(hass, entity_id=None): + """ + Return true if specified automation entity_id is on. + + Check all automation if no entity_id specified. + """ + entity_ids = [entity_id] if entity_id else hass.states.entity_ids(DOMAIN) + return any(hass.states.is_state(entity_id, STATE_ON) + for entity_id in entity_ids) + + +def turn_on(hass, entity_id=None): + """Turn on specified automation or all.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_TURN_ON, data) + + +def turn_off(hass, entity_id=None): + """Turn off specified automation or all.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_TURN_OFF, data) + + +def toggle(hass, entity_id=None): + """Toggle specified automation or all.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_TOGGLE, data) + + +def trigger(hass, entity_id=None): + """Trigger specified automation or all.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_TRIGGER, data) + + +def reload(hass): + """Reload the automation from config.""" + hass.services.call(DOMAIN, SERVICE_RELOAD) + def setup(hass, config): """Setup the automation.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + success = _process_config(hass, config, component) + + if not success: + return False + + descriptions = conf_util.load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + def trigger_service_handler(service_call): + """Handle automation triggers.""" + for entity in component.extract_from_service(service_call): + entity.trigger(service_call.data.get(ATTR_VARIABLES)) + + def service_handler(service_call): + """Handle automation service calls.""" + for entity in component.extract_from_service(service_call): + getattr(entity, service_call.service)() + + def reload_service_handler(service_call): + """Remove all automations and load new ones from config.""" + conf = component.prepare_reload() + if conf is None: + return + _process_config(hass, conf, component) + + hass.services.register(DOMAIN, SERVICE_TRIGGER, trigger_service_handler, + descriptions.get(SERVICE_TRIGGER), + schema=TRIGGER_SERVICE_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_RELOAD, reload_service_handler, + descriptions.get(SERVICE_RELOAD), + schema=RELOAD_SERVICE_SCHEMA) + + for service in (SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE): + hass.services.register(DOMAIN, service, service_handler, + descriptions.get(service), + schema=SERVICE_SCHEMA) + + return True + + +class AutomationEntity(ToggleEntity): + """Entity to show status of entity.""" + + def __init__(self, name, attach_triggers, cond_func, action): + """Initialize an automation entity.""" + self._name = name + self._attach_triggers = attach_triggers + self._detach_triggers = attach_triggers(self.trigger) + self._cond_func = cond_func + self._action = action + self._enabled = True + self._last_triggered = None + + @property + def name(self): + """Name of the automation.""" + return self._name + + @property + def should_poll(self): + """No polling needed for automation entities.""" + return False + + @property + def state_attributes(self): + """Return the entity state attributes.""" + return { + ATTR_LAST_TRIGGERED: self._last_triggered + } + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._enabled + + def turn_on(self, **kwargs) -> None: + """Turn the entity on.""" + if self._enabled: + return + + self._detach_triggers = self._attach_triggers(self.trigger) + self._enabled = True + self.update_ha_state() + + def turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + if not self._enabled: + return + + self._detach_triggers() + self._detach_triggers = None + self._enabled = False + self.update_ha_state() + + def trigger(self, variables): + """Trigger automation.""" + if self._cond_func(variables): + self._action(variables) + self._last_triggered = utcnow() + self.update_ha_state() + + def remove(self): + """Remove automation from HASS.""" + self.turn_off() + super().remove() + + +def _process_config(hass, config, component): + """Process config and add automations.""" success = False + for config_key in extract_domain_configs(config, DOMAIN): conf = config[config_key] for list_no, config_block in enumerate(conf): - name = config_block.get(CONF_ALIAS, "{}, {}".format(config_key, - list_no)) - success = (_setup_automation(hass, config_block, name, config) or - success) + name = config_block.get(CONF_ALIAS) or "{} {}".format(config_key, + list_no) + + action = _get_action(hass, config_block.get(CONF_ACTION, {}), name) + + if CONF_CONDITION in config_block: + cond_func = _process_if(hass, config, config_block) + + if cond_func is None: + continue + else: + def cond_func(variables): + """Condition will always pass.""" + return True + + attach_triggers = partial(_process_trigger, hass, config, + config_block.get(CONF_TRIGGER, []), name) + entity = AutomationEntity(name, attach_triggers, cond_func, action) + component.add_entities((entity,)) + success = True return success -def _setup_automation(hass, config_block, name, config): - """Setup one instance of automation.""" - action = _get_action(hass, config_block.get(CONF_ACTION, {}), name) - - if CONF_CONDITION in config_block: - action = _process_if(hass, config, config_block, action) - - if action is None: - return False - - _process_trigger(hass, config, config_block.get(CONF_TRIGGER, []), name, - action) - return True - - def _get_action(hass, config, name): """Return an action based on a configuration.""" script_obj = script.Script(hass, config, name) @@ -136,7 +315,7 @@ def _get_action(hass, config, name): return action -def _process_if(hass, config, p_config, action): +def _process_if(hass, config, p_config): """Process if checks.""" cond_type = p_config.get(CONF_CONDITION_TYPE, DEFAULT_CONDITION_TYPE).lower() @@ -182,29 +361,43 @@ def _process_if(hass, config, p_config, action): if cond_type == CONDITION_TYPE_AND: def if_action(variables=None): """AND all conditions.""" - if all(check(hass, variables) for check in checks): - action(variables) + return all(check(hass, variables) for check in checks) else: def if_action(variables=None): """OR all conditions.""" - if any(check(hass, variables) for check in checks): - action(variables) + return any(check(hass, variables) for check in checks) return if_action def _process_trigger(hass, config, trigger_configs, name, action): """Setup the triggers.""" + removes = [] + for conf in trigger_configs: platform = _resolve_platform(METHOD_TRIGGER, hass, config, conf.get(CONF_PLATFORM)) if platform is None: continue - if platform.trigger(hass, conf, action): - _LOGGER.info("Initialized rule %s", name) - else: + remove = platform.trigger(hass, conf, action) + + if not remove: _LOGGER.error("Error setting up rule %s", name) + continue + + _LOGGER.info("Initialized rule %s", name) + removes.append(remove) + + if not removes: + return None + + def remove_triggers(): + """Remove attached triggers.""" + for remove in removes: + remove() + + return remove_triggers def _resolve_platform(method, hass, config, platform): diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py index 6b3160996f3..795dd94a71f 100644 --- a/homeassistant/components/automation/event.py +++ b/homeassistant/components/automation/event.py @@ -39,5 +39,4 @@ def trigger(hass, config, action): }, }) - hass.bus.listen(event_type, handle_event) - return True + return hass.bus.listen(event_type, handle_event) diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py index e4a6b221e04..6824c32bf07 100644 --- a/homeassistant/components/automation/mqtt.py +++ b/homeassistant/components/automation/mqtt.py @@ -7,13 +7,12 @@ at https://home-assistant.io/components/automation/#mqtt-trigger import voluptuous as vol import homeassistant.components.mqtt as mqtt -from homeassistant.const import CONF_PLATFORM +from homeassistant.const import (CONF_PLATFORM, CONF_PAYLOAD) import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['mqtt'] CONF_TOPIC = 'topic' -CONF_PAYLOAD = 'payload' TRIGGER_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): mqtt.DOMAIN, @@ -24,7 +23,7 @@ TRIGGER_SCHEMA = vol.Schema({ def trigger(hass, config, action): """Listen for state changes based on configuration.""" - topic = config[CONF_TOPIC] + topic = config.get(CONF_TOPIC) payload = config.get(CONF_PAYLOAD) def mqtt_automation_listener(msg_topic, msg_payload, qos): @@ -39,6 +38,4 @@ def trigger(hass, config, action): } }) - mqtt.subscribe(hass, topic, mqtt_automation_listener) - - return True + return mqtt.subscribe(hass, topic, mqtt_automation_listener) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 3a148b0880f..608063b4708 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -63,7 +63,4 @@ def trigger(hass, config, action): action(variables) - track_state_change( - hass, entity_id, state_automation_listener) - - return True + return track_state_change(hass, entity_id, state_automation_listener) diff --git a/homeassistant/components/automation/services.yaml b/homeassistant/components/automation/services.yaml new file mode 100644 index 00000000000..ee22b671eca --- /dev/null +++ b/homeassistant/components/automation/services.yaml @@ -0,0 +1,34 @@ +turn_on: + description: Enable an automation. + + fields: + entity_id: + description: Name of the automation to turn on. + example: 'automation.notify_home' + +turn_off: + description: Disable an automation. + + fields: + entity_id: + description: Name of the automation to turn off. + example: 'automation.notify_home' + +toggle: + description: Toggle an automation. + + fields: + entity_id: + description: Name of the automation to toggle on/off. + example: 'automation.notify_home' + +trigger: + description: Trigger the action of an automation. + + fields: + entity_id: + description: Name of the automation to trigger. + example: 'automation.notify_home' + +reload: + description: Reload the automation configuration. diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index 03902c1d6e2..8e0eb5231a5 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -7,8 +7,7 @@ at https://home-assistant.io/components/automation/#state-trigger import voluptuous as vol import homeassistant.util.dt as dt_util -from homeassistant.const import ( - EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL, CONF_PLATFORM) +from homeassistant.const import MATCH_ALL, CONF_PLATFORM from homeassistant.helpers.event import track_state_change, track_point_in_time import homeassistant.helpers.config_validation as cv @@ -39,9 +38,13 @@ def trigger(hass, config, action): from_state = config.get(CONF_FROM, MATCH_ALL) to_state = config.get(CONF_TO) or config.get(CONF_STATE) or MATCH_ALL time_delta = config.get(CONF_FOR) + remove_state_for_cancel = None + remove_state_for_listener = None def state_automation_listener(entity, from_s, to_s): """Listen for state changes and calls action.""" + nonlocal remove_state_for_cancel, remove_state_for_listener + def call_action(): """Call action with right context.""" action({ @@ -60,26 +63,33 @@ def trigger(hass, config, action): def state_for_listener(now): """Fire on state changes after a delay and calls action.""" - hass.bus.remove_listener( - EVENT_STATE_CHANGED, attached_state_for_cancel) + remove_state_for_cancel() call_action() def state_for_cancel_listener(entity, inner_from_s, inner_to_s): """Fire on changes and cancel for listener if changed.""" if inner_to_s.state == to_s.state: return - hass.bus.remove_listener(EVENT_TIME_CHANGED, - attached_state_for_listener) - hass.bus.remove_listener(EVENT_STATE_CHANGED, - attached_state_for_cancel) + remove_state_for_listener() + remove_state_for_cancel() - attached_state_for_listener = track_point_in_time( + remove_state_for_listener = track_point_in_time( hass, state_for_listener, dt_util.utcnow() + time_delta) - attached_state_for_cancel = track_state_change( + remove_state_for_cancel = track_state_change( hass, entity, state_for_cancel_listener) - track_state_change( - hass, entity_id, state_automation_listener, from_state, to_state) + unsub = track_state_change(hass, entity_id, state_automation_listener, + from_state, to_state) - return True + def remove(): + """Remove state listeners.""" + unsub() + # pylint: disable=not-callable + if remove_state_for_cancel is not None: + remove_state_for_cancel() + + if remove_state_for_listener is not None: + remove_state_for_listener() + + return remove diff --git a/homeassistant/components/automation/sun.py b/homeassistant/components/automation/sun.py index 7666847575e..991f9b3b385 100644 --- a/homeassistant/components/automation/sun.py +++ b/homeassistant/components/automation/sun.py @@ -42,8 +42,6 @@ def trigger(hass, config, action): # Do something to call action if event == SUN_EVENT_SUNRISE: - track_sunrise(hass, call_action, offset) + return track_sunrise(hass, call_action, offset) else: - track_sunset(hass, call_action, offset) - - return True + return track_sunset(hass, call_action, offset) diff --git a/homeassistant/components/automation/template.py b/homeassistant/components/automation/template.py index 1cfbf45a24d..0891590a539 100644 --- a/homeassistant/components/automation/template.py +++ b/homeassistant/components/automation/template.py @@ -49,5 +49,4 @@ def trigger(hass, config, action): elif not template_result: already_triggered = False - track_state_change(hass, MATCH_ALL, state_changed_listener) - return True + return track_state_change(hass, MATCH_ALL, state_changed_listener) diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index ca80536ea96..0732e2b212c 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -47,7 +47,5 @@ def trigger(hass, config, action): }, }) - track_time_change(hass, time_automation_listener, - hour=hours, minute=minutes, second=seconds) - - return True + return track_time_change(hass, time_automation_listener, + hour=hours, minute=minutes, second=seconds) diff --git a/homeassistant/components/automation/zone.py b/homeassistant/components/automation/zone.py index 5578bf052c4..ec948684805 100644 --- a/homeassistant/components/automation/zone.py +++ b/homeassistant/components/automation/zone.py @@ -58,7 +58,5 @@ def trigger(hass, config, action): }, }) - track_state_change( - hass, entity_id, zone_automation_listener, MATCH_ALL, MATCH_ALL) - - return True + return track_state_change(hass, entity_id, zone_automation_listener, + MATCH_ALL, MATCH_ALL) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 2f751683265..18e33ffe738 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -27,6 +27,7 @@ SENSOR_CLASSES = [ 'moisture', # Specifically a wetness sensor 'motion', # Motion sensor 'moving', # On means moving, Off means stopped + 'occupancy', # On means occupied, Off means not occupied 'opening', # Door, window, etc. 'power', # Power, over-current, etc 'safety', # Generic on=unsafe, off=safe diff --git a/homeassistant/components/binary_sensor/bloomsky.py b/homeassistant/components/binary_sensor/bloomsky.py index f9e192c7984..2419d6f766e 100644 --- a/homeassistant/components/binary_sensor/bloomsky.py +++ b/homeassistant/components/binary_sensor/bloomsky.py @@ -6,32 +6,39 @@ https://home-assistant.io/components/binary_sensor.bloomsky/ """ import logging -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.loader import get_component +import voluptuous as vol -DEPENDENCIES = ["bloomsky"] +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.loader import get_component +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['bloomsky'] # These are the available sensors mapped to binary_sensor class SENSOR_TYPES = { - "Rain": "moisture", - "Night": None, + 'Rain': 'moisture', + 'Night': None, } +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the available BloomSky weather binary sensors.""" - logger = logging.getLogger(__name__) bloomsky = get_component('bloomsky') - sensors = config.get('monitored_conditions', SENSOR_TYPES) + # Default needed in case of discovery + sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES) for device in bloomsky.BLOOMSKY.devices.values(): for variable in sensors: - if variable in SENSOR_TYPES: - add_devices([BloomSkySensor(bloomsky.BLOOMSKY, - device, - variable)]) - else: - logger.error("Cannot find definition for device: %s", variable) + add_devices([BloomSkySensor(bloomsky.BLOOMSKY, device, variable)]) class BloomSkySensor(BinarySensorDevice): @@ -40,10 +47,10 @@ class BloomSkySensor(BinarySensorDevice): def __init__(self, bs, device, sensor_name): """Initialize a BloomSky binary sensor.""" self._bloomsky = bs - self._device_id = device["DeviceID"] + self._device_id = device['DeviceID'] self._sensor_name = sensor_name - self._name = "{} {}".format(device["DeviceName"], sensor_name) - self._unique_id = "bloomsky_binary_sensor {}".format(self._name) + self._name = '{} {}'.format(device['DeviceName'], sensor_name) + self._unique_id = 'bloomsky_binary_sensor {}'.format(self._name) self.update() @property @@ -71,4 +78,4 @@ class BloomSkySensor(BinarySensorDevice): self._bloomsky.refresh_devices() self._state = \ - self._bloomsky.devices[self._device_id]["Data"][self._sensor_name] + self._bloomsky.devices[self._device_id]['Data'][self._sensor_name] diff --git a/homeassistant/components/binary_sensor/command_line.py b/homeassistant/components/binary_sensor/command_line.py index e589506eac7..f56f9cb7a39 100644 --- a/homeassistant/components/binary_sensor/command_line.py +++ b/homeassistant/components/binary_sensor/command_line.py @@ -7,46 +7,50 @@ https://home-assistant.io/components/binary_sensor.command_line/ import logging from datetime import timedelta -from homeassistant.components.binary_sensor import (BinarySensorDevice, - SENSOR_CLASSES) +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, SENSOR_CLASSES_SCHEMA, PLATFORM_SCHEMA) from homeassistant.components.sensor.command_line import CommandSensorData -from homeassistant.const import CONF_VALUE_TEMPLATE +from homeassistant.const import ( + CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, CONF_NAME, CONF_VALUE_TEMPLATE, + CONF_SENSOR_CLASS, CONF_COMMAND) from homeassistant.helpers import template +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "Binary Command Sensor" -DEFAULT_SENSOR_CLASS = None +DEFAULT_NAME = 'Binary Command Sensor' DEFAULT_PAYLOAD_ON = 'ON' DEFAULT_PAYLOAD_OFF = 'OFF' -# Return cached results if last scan was less then this time ago MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + 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_SENSOR_CLASS): SENSOR_CLASSES_SCHEMA, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the Command Sensor.""" - if config.get('command') is None: - _LOGGER.error('Missing required variable: "command"') - return False + """Setup the Command line Binary Sensor.""" + name = config.get(CONF_NAME) + command = config.get(CONF_COMMAND) + payload_off = config.get(CONF_PAYLOAD_OFF) + payload_on = config.get(CONF_PAYLOAD_ON) + sensor_class = config.get(CONF_SENSOR_CLASS) + value_template = config.get(CONF_VALUE_TEMPLATE) - sensor_class = config.get('sensor_class') - if sensor_class not in SENSOR_CLASSES: - _LOGGER.warning('Unknown sensor class: %s', sensor_class) - sensor_class = DEFAULT_SENSOR_CLASS - - data = CommandSensorData(config.get('command')) + data = CommandSensorData(command) add_devices([CommandBinarySensor( - hass, - data, - config.get('name', DEFAULT_NAME), - sensor_class, - config.get('payload_on', DEFAULT_PAYLOAD_ON), - config.get('payload_off', DEFAULT_PAYLOAD_OFF), - config.get(CONF_VALUE_TEMPLATE) - )]) + hass, data, name, sensor_class, payload_on, payload_off, + value_template)]) # pylint: disable=too-many-arguments, too-many-instance-attributes diff --git a/homeassistant/components/binary_sensor/ecobee.py b/homeassistant/components/binary_sensor/ecobee.py index 09cbfd852e3..93583ff08b1 100644 --- a/homeassistant/components/binary_sensor/ecobee.py +++ b/homeassistant/components/binary_sensor/ecobee.py @@ -2,7 +2,7 @@ Support for Ecobee sensors. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.ecobee/ +https://home-assistant.io/components/binary_sensor.ecobee/ """ from homeassistant.components import ecobee from homeassistant.components.binary_sensor import BinarySensorDevice @@ -38,7 +38,7 @@ class EcobeeBinarySensor(BinarySensorDevice): self.sensor_name = sensor_name self.index = sensor_index self._state = None - self._sensor_class = 'motion' + self._sensor_class = 'occupancy' self.update() @property diff --git a/homeassistant/components/binary_sensor/ffmpeg.py b/homeassistant/components/binary_sensor/ffmpeg.py index e02a560ec54..9c37ff7744c 100644 --- a/homeassistant/components/binary_sensor/ffmpeg.py +++ b/homeassistant/components/binary_sensor/ffmpeg.py @@ -16,7 +16,7 @@ from homeassistant.config import load_yaml_config_file from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, CONF_NAME, ATTR_ENTITY_ID) -REQUIREMENTS = ["ha-ffmpeg==0.9"] +REQUIREMENTS = ["ha-ffmpeg==0.10"] SERVICE_RESTART = 'ffmpeg_restart' diff --git a/homeassistant/components/binary_sensor/homematic.py b/homeassistant/components/binary_sensor/homematic.py index 117642c65f1..073f5d7eb6d 100644 --- a/homeassistant/components/binary_sensor/homematic.py +++ b/homeassistant/components/binary_sensor/homematic.py @@ -31,9 +31,11 @@ def setup_platform(hass, config, add_callback_devices, discovery_info=None): if discovery_info is None: return - return homematic.setup_hmdevice_discovery_helper(HMBinarySensor, - discovery_info, - add_callback_devices) + return homematic.setup_hmdevice_discovery_helper( + HMBinarySensor, + discovery_info, + add_callback_devices + ) class HMBinarySensor(homematic.HMDevice, BinarySensorDevice): @@ -57,44 +59,8 @@ class HMBinarySensor(homematic.HMDevice, BinarySensorDevice): return "motion" return SENSOR_TYPES_CLASS.get(self._hmdevice.__class__.__name__, None) - def _check_hm_to_ha_object(self): - """Check if possible to use the HM Object as this HA type.""" - from pyhomematic.devicetypes.sensors import HMBinarySensor\ - as pyHMBinarySensor - - # Check compatibility from HMDevice - if not super()._check_hm_to_ha_object(): - return False - - # check if the Homematic device correct for this HA device - if not isinstance(self._hmdevice, pyHMBinarySensor): - _LOGGER.critical("This %s can't be use as binary", self._name) - return False - - # if exists user value? - if self._state and self._state not in self._hmdevice.BINARYNODE: - _LOGGER.critical("This %s have no binary with %s", self._name, - self._state) - return False - - # only check and give a warning to the user - if self._state is None and len(self._hmdevice.BINARYNODE) > 1: - _LOGGER.critical("%s have multiple binary params. It use all " - "binary nodes as one. Possible param values: %s", - self._name, str(self._hmdevice.BINARYNODE)) - return False - - return True - def _init_data_struct(self): """Generate a data struct (self._data) from the Homematic metadata.""" - super()._init_data_struct() - - # object have 1 binary - if self._state is None and len(self._hmdevice.BINARYNODE) == 1: - for value in self._hmdevice.BINARYNODE: - self._state = value - # add state to data struct if self._state: _LOGGER.debug("%s init datastruct with main node '%s'", self._name, diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index a381305691a..fd767bb1528 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -9,45 +9,42 @@ import logging import voluptuous as vol import homeassistant.components.mqtt as mqtt -from homeassistant.components.binary_sensor import (BinarySensorDevice, - SENSOR_CLASSES) -from homeassistant.const import CONF_NAME, CONF_VALUE_TEMPLATE -from homeassistant.components.mqtt import CONF_STATE_TOPIC, CONF_QOS +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, SENSOR_CLASSES) +from homeassistant.const import ( + CONF_NAME, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF, + CONF_SENSOR_CLASS) +from homeassistant.components.mqtt import (CONF_STATE_TOPIC, CONF_QOS) from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ['mqtt'] - -CONF_SENSOR_CLASS = 'sensor_class' -CONF_PAYLOAD_ON = 'payload_on' -CONF_PAYLOAD_OFF = 'payload_off' - DEFAULT_NAME = 'MQTT Binary sensor' -DEFAULT_PAYLOAD_ON = 'ON' DEFAULT_PAYLOAD_OFF = 'OFF' +DEFAULT_PAYLOAD_ON = 'ON' +DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + 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_SENSOR_CLASS, default=None): vol.Any(vol.In(SENSOR_CLASSES), vol.SetTo(None)), - vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, - vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, }) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Add MQTT binary sensor.""" + """Setup the MQTT binary sensor.""" add_devices([MqttBinarySensor( hass, - config[CONF_NAME], - config[CONF_STATE_TOPIC], - config[CONF_SENSOR_CLASS], - config[CONF_QOS], - config[CONF_PAYLOAD_ON], - config[CONF_PAYLOAD_OFF], + config.get(CONF_NAME), + config.get(CONF_STATE_TOPIC), + config.get(CONF_SENSOR_CLASS), + config.get(CONF_QOS), + config.get(CONF_PAYLOAD_ON), + config.get(CONF_PAYLOAD_OFF), config.get(CONF_VALUE_TEMPLATE) )]) diff --git a/homeassistant/components/binary_sensor/nest.py b/homeassistant/components/binary_sensor/nest.py index 9f963b730b5..4dfe4d58b99 100644 --- a/homeassistant/components/binary_sensor/nest.py +++ b/homeassistant/components/binary_sensor/nest.py @@ -6,12 +6,12 @@ https://home-assistant.io/components/binary_sensor.nest/ """ import voluptuous as vol -import homeassistant.components.nest as nest -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) from homeassistant.components.sensor.nest import NestSensor -from homeassistant.const import ( - CONF_PLATFORM, CONF_SCAN_INTERVAL, CONF_MONITORED_CONDITIONS -) +from homeassistant.const import (CONF_SCAN_INTERVAL, CONF_MONITORED_CONDITIONS) +import homeassistant.components.nest as nest +import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['nest'] BINARY_TYPES = ['fan', @@ -25,11 +25,11 @@ BINARY_TYPES = ['fan', 'hvac_emer_heat_state', 'online'] -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): nest.DOMAIN, +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SCAN_INTERVAL): vol.All(vol.Coerce(int), vol.Range(min=1)), - vol.Required(CONF_MONITORED_CONDITIONS): [vol.In(BINARY_TYPES)], + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(BINARY_TYPES)]), }) diff --git a/homeassistant/components/binary_sensor/octoprint.py b/homeassistant/components/binary_sensor/octoprint.py index 25c922ca20c..6763eaafa55 100644 --- a/homeassistant/components/binary_sensor/octoprint.py +++ b/homeassistant/components/binary_sensor/octoprint.py @@ -5,45 +5,56 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.octoprint/ """ import logging + import requests +import voluptuous as vol -from homeassistant.const import CONF_NAME, STATE_ON, STATE_OFF -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import ( + CONF_NAME, STATE_ON, STATE_OFF, CONF_MONITORED_CONDITIONS) +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) from homeassistant.loader import get_component +import homeassistant.helpers.config_validation as cv -DEPENDENCIES = ["octoprint"] + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['octoprint'] + +DEFAULT_NAME = 'OctoPrint' SENSOR_TYPES = { # API Endpoint, Group, Key, unit - "Printing": ["printer", "state", "printing", None], - "Printing Error": ["printer", "state", "error", None] + 'Printing': ['printer', 'state', 'printing', None], + 'Printing Error': ['printer', 'state', 'error', None] } -_LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the available OctoPrint binary sensors.""" octoprint = get_component('octoprint') - name = config.get(CONF_NAME, "OctoPrint") - monitored_conditions = config.get("monitored_conditions", + name = config.get(CONF_NAME) + monitored_conditions = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES.keys()) devices = [] for octo_type in monitored_conditions: - if octo_type in SENSOR_TYPES: - new_sensor = OctoPrintBinarySensor(octoprint.OCTOPRINT, - octo_type, - SENSOR_TYPES[octo_type][2], - name, - SENSOR_TYPES[octo_type][3], - SENSOR_TYPES[octo_type][0], - SENSOR_TYPES[octo_type][1], - "flags") - devices.append(new_sensor) - else: - _LOGGER.error("Unknown OctoPrint sensor type: %s", octo_type) + new_sensor = OctoPrintBinarySensor(octoprint.OCTOPRINT, + octo_type, + SENSOR_TYPES[octo_type][2], + name, + SENSOR_TYPES[octo_type][3], + SENSOR_TYPES[octo_type][0], + SENSOR_TYPES[octo_type][1], + 'flags') + devices.append(new_sensor) add_devices(devices) @@ -52,14 +63,14 @@ class OctoPrintBinarySensor(BinarySensorDevice): """Representation an OctoPrint binary sensor.""" # pylint: disable=too-many-arguments - def __init__(self, api, condition, sensor_type, sensor_name, - unit, endpoint, group, tool=None): + def __init__(self, api, condition, sensor_type, sensor_name, unit, + endpoint, group, tool=None): """Initialize a new OctoPrint sensor.""" self.sensor_name = sensor_name if tool is None: - self._name = sensor_name + ' ' + condition + self._name = '{} {}'.format(sensor_name, condition) else: - self._name = sensor_name + ' ' + condition + self._name = '{} {}'.format(sensor_name, condition) self.sensor_type = sensor_type self.api = api self._state = False diff --git a/homeassistant/components/binary_sensor/rest.py b/homeassistant/components/binary_sensor/rest.py index 4a6e48ca5a3..71666b91d06 100644 --- a/homeassistant/components/binary_sensor/rest.py +++ b/homeassistant/components/binary_sensor/rest.py @@ -13,12 +13,15 @@ from homeassistant.components.binary_sensor import ( from homeassistant.components.sensor.rest import RestData from homeassistant.const import ( CONF_PAYLOAD, CONF_NAME, CONF_VALUE_TEMPLATE, CONF_METHOD, CONF_RESOURCE, - CONF_SENSOR_CLASS) + CONF_SENSOR_CLASS, CONF_VERIFY_SSL) from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv +_LOGGER = logging.getLogger(__name__) + DEFAULT_METHOD = 'GET' DEFAULT_NAME = 'REST Binary Sensor' +DEFAULT_VERIFY_SSL = True PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_RESOURCE): cv.url, @@ -27,10 +30,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PAYLOAD): cv.string, vol.Optional(CONF_SENSOR_CLASS): SENSOR_CLASSES_SCHEMA, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, }) -_LOGGER = logging.getLogger(__name__) - # pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): @@ -39,7 +41,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): resource = config.get(CONF_RESOURCE) method = config.get(CONF_METHOD) payload = config.get(CONF_PAYLOAD) - verify_ssl = config.get('verify_ssl', True) + verify_ssl = config.get(CONF_VERIFY_SSL) sensor_class = config.get(CONF_SENSOR_CLASS) value_template = config.get(CONF_VALUE_TEMPLATE) diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index e87594e625c..e0b748bbbbe 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -5,22 +5,22 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.template/ """ import logging + import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, + SENSOR_CLASSES_SCHEMA) +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, MATCH_ALL, CONF_VALUE_TEMPLATE, + CONF_SENSOR_CLASS, CONF_SENSORS) +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template +from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.event import track_state_change import homeassistant.helpers.config_validation as cv -from homeassistant.components.binary_sensor import (BinarySensorDevice, - ENTITY_ID_FORMAT, - PLATFORM_SCHEMA, - SENSOR_CLASSES_SCHEMA) - -from homeassistant.const import (ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, MATCH_ALL, - CONF_VALUE_TEMPLATE, CONF_SENSOR_CLASS) -from homeassistant.exceptions import TemplateError -from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers import template -from homeassistant.helpers.event import track_state_change - -CONF_SENSORS = 'sensors' +_LOGGER = logging.getLogger(__name__) SENSOR_SCHEMA = vol.Schema({ vol.Required(CONF_VALUE_TEMPLATE): cv.template, @@ -33,15 +33,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SENSORS): vol.Schema({cv.slug: SENSOR_SCHEMA}), }) -_LOGGER = logging.getLogger(__name__) - def setup_platform(hass, config, add_devices, discovery_info=None): """Setup template binary sensors.""" sensors = [] for device, device_config in config[CONF_SENSORS].items(): - value_template = device_config[CONF_VALUE_TEMPLATE] entity_ids = device_config[ATTR_ENTITY_ID] friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) @@ -85,8 +82,7 @@ class BinarySensorTemplate(BinarySensorDevice): """Called when the target device changes state.""" self.update_ha_state(True) - track_state_change(hass, entity_ids, - template_bsensor_state_listener) + track_state_change(hass, entity_ids, template_bsensor_state_listener) @property def name(self): @@ -111,8 +107,8 @@ class BinarySensorTemplate(BinarySensorDevice): def update(self): """Get the latest data and update the state.""" try: - self._state = template.render(self.hass, - self._template).lower() == 'true' + self._state = template.render( + self.hass, self._template).lower() == 'true' except TemplateError as ex: if ex.args and ex.args[0].startswith( "UndefinedError: 'None' has no attribute"): diff --git a/homeassistant/components/binary_sensor/trend.py b/homeassistant/components/binary_sensor/trend.py new file mode 100644 index 00000000000..940f80a757b --- /dev/null +++ b/homeassistant/components/binary_sensor/trend.py @@ -0,0 +1,145 @@ +""" +A sensor that monitors trands in other components. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.template/ +""" +import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, + ENTITY_ID_FORMAT, + PLATFORM_SCHEMA, + SENSOR_CLASSES_SCHEMA) +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + ATTR_ENTITY_ID, + CONF_SENSOR_CLASS, + STATE_UNKNOWN,) +from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.event import track_state_change + +_LOGGER = logging.getLogger(__name__) +CONF_SENSORS = 'sensors' +CONF_ATTRIBUTE = 'attribute' +CONF_INVERT = 'invert' + +SENSOR_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_ATTRIBUTE): cv.string, + vol.Optional(ATTR_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_INVERT, default=False): cv.boolean, + vol.Optional(CONF_SENSOR_CLASS, default=None): SENSOR_CLASSES_SCHEMA + +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SENSORS): vol.Schema({cv.slug: SENSOR_SCHEMA}), +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the template sensors.""" + sensors = [] + + for device, device_config in config[CONF_SENSORS].items(): + entity_id = device_config[ATTR_ENTITY_ID] + attribute = device_config.get(CONF_ATTRIBUTE) + friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) + sensor_class = device_config[CONF_SENSOR_CLASS] + invert = device_config[CONF_INVERT] + + sensors.append( + SensorTrend( + hass, + device, + friendly_name, + entity_id, + attribute, + sensor_class, + invert) + ) + if not sensors: + _LOGGER.error("No sensors added") + return False + add_devices(sensors) + return True + + +class SensorTrend(BinarySensorDevice): + """Representation of a Template Sensor.""" + + # pylint: disable=too-many-arguments, too-many-instance-attributes + def __init__(self, hass, device_id, friendly_name, + target_entity, attribute, sensor_class, invert): + """Initialize the sensor.""" + self._hass = hass + self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, + hass=hass) + self._name = friendly_name + self._target_entity = target_entity + self._attribute = attribute + self._sensor_class = sensor_class + self._invert = invert + self._state = None + self.from_state = None + self.to_state = None + + self.update() + + def template_sensor_state_listener(entity, old_state, new_state): + """Called when the target device changes state.""" + self.from_state = old_state + self.to_state = new_state + self.update_ha_state(True) + + track_state_change(hass, target_entity, + template_sensor_state_listener) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state + + @property + def sensor_class(self): + """Return the sensor class of the sensor.""" + return self._sensor_class + + @property + def should_poll(self): + """No polling needed.""" + return False + + def update(self): + """Get the latest data and update the states.""" + if self.from_state is None or self.to_state is None: + return + if (self.from_state.state == STATE_UNKNOWN or + self.to_state.state == STATE_UNKNOWN): + return + try: + if self._attribute: + from_value = float( + self.from_state.attributes.get(self._attribute)) + to_value = float( + self.to_state.attributes.get(self._attribute)) + else: + from_value = float(self.from_state.state) + to_value = float(self.to_state.state) + + self._state = to_value > from_value + if self._invert: + self._state = not self._state + + except (ValueError, TypeError) as ex: + self._state = None + _LOGGER.error(ex) diff --git a/homeassistant/components/binary_sensor/zigbee.py b/homeassistant/components/binary_sensor/zigbee.py index 7e4139d4680..2eb508304d4 100644 --- a/homeassistant/components/binary_sensor/zigbee.py +++ b/homeassistant/components/binary_sensor/zigbee.py @@ -4,18 +4,27 @@ Contains functionality to use a ZigBee device as a binary sensor. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.zigbee/ """ +import voluptuous as vol + from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.zigbee import ( - ZigBeeDigitalIn, ZigBeeDigitalInConfig) + ZigBeeDigitalIn, ZigBeeDigitalInConfig, PLATFORM_SCHEMA) -DEPENDENCIES = ["zigbee"] +CONF_ON_STATE = 'on_state' + +DEFAULT_ON_STATE = 'high' +DEPENDENCIES = ['zigbee'] + +STATES = ['high', 'low'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_ON_STATE): vol.In(STATES), +}) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the ZigBee binary sensor platform.""" - add_entities([ - ZigBeeBinarySensor(hass, ZigBeeDigitalInConfig(config)) - ]) + add_devices([ZigBeeBinarySensor(hass, ZigBeeDigitalInConfig(config))]) class ZigBeeBinarySensor(ZigBeeDigitalIn, BinarySensorDevice): diff --git a/homeassistant/components/bloomsky.py b/homeassistant/components/bloomsky.py index b881dcb9526..e610082951b 100644 --- a/homeassistant/components/bloomsky.py +++ b/homeassistant/components/bloomsky.py @@ -8,30 +8,34 @@ import logging from datetime import timedelta import requests +import voluptuous as vol from homeassistant.const import CONF_API_KEY -from homeassistant.helpers import validate_config, discovery +from homeassistant.helpers import discovery from homeassistant.util import Throttle - -DOMAIN = "bloomsky" -BLOOMSKY = None +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +BLOOMSKY = None +BLOOMSKY_TYPE = ['camera', 'binary_sensor', 'sensor'] + +DOMAIN = 'bloomsky' + # The BloomSky only updates every 5-8 minutes as per the API spec so there's # no point in polling the API more frequently MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + # pylint: disable=unused-argument,too-few-public-methods def setup(hass, config): """Setup BloomSky component.""" - if not validate_config( - config, - {DOMAIN: [CONF_API_KEY]}, - _LOGGER): - return False - api_key = config[DOMAIN][CONF_API_KEY] global BLOOMSKY @@ -40,7 +44,7 @@ def setup(hass, config): except RuntimeError: return False - for component in 'camera', 'binary_sensor', 'sensor': + for component in BLOOMSKY_TYPE: discovery.load_platform(hass, component, DOMAIN, {}, config) return True @@ -50,19 +54,19 @@ class BloomSky(object): """Handle all communication with the BloomSky API.""" # API documentation at http://weatherlution.com/bloomsky-api/ - API_URL = "https://api.bloomsky.com/api/skydata" + API_URL = 'https://api.bloomsky.com/api/skydata' def __init__(self, api_key): """Initialize the BookSky.""" self._api_key = api_key self.devices = {} - _LOGGER.debug("Initial bloomsky device load...") + _LOGGER.debug("Initial BloomSky device load...") self.refresh_devices() @Throttle(MIN_TIME_BETWEEN_UPDATES) def refresh_devices(self): """Use the API to retreive a list of devices.""" - _LOGGER.debug("Fetching bloomsky update") + _LOGGER.debug("Fetching BloomSky update") response = requests.get(self.API_URL, headers={"Authorization": self._api_key}, timeout=10) @@ -73,5 +77,5 @@ class BloomSky(object): return # Create dictionary keyed off of the device unique id self.devices.update({ - device["DeviceID"]: device for device in response.json() + device['DeviceID']: device for device in response.json() }) diff --git a/homeassistant/components/camera/ffmpeg.py b/homeassistant/components/camera/ffmpeg.py index 23d6874cd81..af21537e3c3 100644 --- a/homeassistant/components/camera/ffmpeg.py +++ b/homeassistant/components/camera/ffmpeg.py @@ -5,16 +5,14 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/camera.ffmpeg/ """ import logging -from contextlib import closing import voluptuous as vol from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA) -from homeassistant.components.camera.mjpeg import extract_image_from_mjpeg import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_NAME -REQUIREMENTS = ['ha-ffmpeg==0.9'] +REQUIREMENTS = ['ha-ffmpeg==0.10'] _LOGGER = logging.getLogger(__name__) @@ -49,22 +47,20 @@ class FFmpegCamera(Camera): self._extra_arguments = config.get(CONF_EXTRA_ARGUMENTS) self._ffmpeg_bin = config.get(CONF_FFMPEG_BIN) - def _ffmpeg_stream(self): - """Return a FFmpeg process object.""" - from haffmpeg import CameraMjpeg - - ffmpeg = CameraMjpeg(self._ffmpeg_bin) - ffmpeg.open_camera(self._input, extra_cmd=self._extra_arguments) - return ffmpeg - def camera_image(self): """Return a still image response from the camera.""" - with closing(self._ffmpeg_stream()) as stream: - return extract_image_from_mjpeg(stream) + from haffmpeg import ImageSingle, IMAGE_JPEG + ffmpeg = ImageSingle(self._ffmpeg_bin) + + return ffmpeg.get_image(self._input, output_format=IMAGE_JPEG, + extra_cmd=self._extra_arguments) def mjpeg_stream(self, response): """Generate an HTTP MJPEG stream from the camera.""" - stream = self._ffmpeg_stream() + from haffmpeg import CameraMjpeg + + stream = CameraMjpeg(self._ffmpeg_bin) + stream.open_camera(self._input, extra_cmd=self._extra_arguments) return response( stream, mimetype='multipart/x-mixed-replace;boundary=ffserver', diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index e4215bcea85..726ed4f674c 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -12,7 +12,6 @@ import voluptuous as vol from homeassistant.helpers.entity_component import EntityComponent from homeassistant.config import load_yaml_config_file -import homeassistant.util as util from homeassistant.util.temperature import convert as convert_temperature from homeassistant.helpers.entity import Entity from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa @@ -44,6 +43,8 @@ STATE_FAN_ONLY = "fan_only" ATTR_CURRENT_TEMPERATURE = "current_temperature" ATTR_MAX_TEMP = "max_temp" ATTR_MIN_TEMP = "min_temp" +ATTR_TARGET_TEMP_HIGH = "target_temp_high" +ATTR_TARGET_TEMP_LOW = "target_temp_low" ATTR_AWAY_MODE = "away_mode" ATTR_AUX_HEAT = "aux_heat" ATTR_FAN_MODE = "fan_mode" @@ -68,8 +69,10 @@ SET_AUX_HEAT_SCHEMA = vol.Schema({ vol.Required(ATTR_AUX_HEAT): cv.boolean, }) SET_TEMPERATURE_SCHEMA = vol.Schema({ + vol.Exclusive(ATTR_TEMPERATURE, 'temperature'): vol.Coerce(float), + vol.Inclusive(ATTR_TARGET_TEMP_HIGH, 'temperature'): vol.Coerce(float), + vol.Inclusive(ATTR_TARGET_TEMP_LOW, 'temperature'): vol.Coerce(float), vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_TEMPERATURE): vol.Coerce(float), }) SET_FAN_MODE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, @@ -113,14 +116,19 @@ def set_aux_heat(hass, aux_heat, entity_id=None): hass.services.call(DOMAIN, SERVICE_SET_AUX_HEAT, data) -def set_temperature(hass, temperature, entity_id=None): +def set_temperature(hass, temperature=None, entity_id=None, + target_temp_high=None, target_temp_low=None): """Set new target temperature.""" - data = {ATTR_TEMPERATURE: temperature} - - if entity_id is not None: - data[ATTR_ENTITY_ID] = entity_id - - hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, data) + kwargs = { + key: value for key, value in [ + (ATTR_TEMPERATURE, temperature), + (ATTR_TARGET_TEMP_HIGH, target_temp_high), + (ATTR_TARGET_TEMP_LOW, target_temp_low), + (ATTR_ENTITY_ID, entity_id), + ] if value is not None + } + _LOGGER.debug("set_temperature start data=%s", kwargs) + hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, kwargs) def set_humidity(hass, humidity, entity_id=None): @@ -227,20 +235,9 @@ def setup(hass, config): def temperature_set_service(service): """Set temperature on the target climate devices.""" target_climate = component.extract_from_service(service) - - temperature = util.convert( - service.data.get(ATTR_TEMPERATURE), float) - - if temperature is None: - _LOGGER.error( - "Received call to %s without attribute %s", - SERVICE_SET_TEMPERATURE, ATTR_TEMPERATURE) - return - + kwargs = service.data for climate in target_climate: - climate.set_temperature(convert_temperature( - temperature, hass.config.units.temperature_unit, - climate.unit_of_measurement)) + climate.set_temperature(**kwargs) if climate.should_poll: climate.update_ha_state(True) @@ -351,7 +348,7 @@ class ClimateDevice(Entity): @property def state(self): """Return the current state.""" - return self.target_temperature or STATE_UNKNOWN + return self.current_operation or STATE_UNKNOWN @property def state_attributes(self): @@ -364,6 +361,12 @@ class ClimateDevice(Entity): ATTR_TEMPERATURE: self._convert_for_display(self.target_temperature), } + target_temp_high = self.target_temperature_high + if target_temp_high is not None: + data[ATTR_TARGET_TEMP_HIGH] = self._convert_for_display( + self.target_temperature_high) + data[ATTR_TARGET_TEMP_LOW] = self._convert_for_display( + self.target_temperature_low) humidity = self.target_humidity if humidity is not None: @@ -432,6 +435,16 @@ class ClimateDevice(Entity): """Return the temperature we try to reach.""" return None + @property + def target_temperature_high(self): + """Return the highbound target temperature we try to reach.""" + return None + + @property + def target_temperature_low(self): + """Return the lowbound target temperature we try to reach.""" + return None + @property def is_away_mode_on(self): """Return true if away mode is on.""" @@ -462,7 +475,7 @@ class ClimateDevice(Entity): """List of available swing modes.""" return None - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" raise NotImplementedError() diff --git a/homeassistant/components/climate/demo.py b/homeassistant/components/climate/demo.py index 340cc29f582..cb85a153cc8 100644 --- a/homeassistant/components/climate/demo.py +++ b/homeassistant/components/climate/demo.py @@ -4,17 +4,20 @@ Demo platform that offers a fake climate device. For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ -from homeassistant.components.climate import ClimateDevice -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.components.climate import ( + ClimateDevice, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW) +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Demo climate devices.""" add_devices([ DemoClimate("HeatPump", 68, TEMP_FAHRENHEIT, None, 77, "Auto Low", - None, None, "Auto", "Heat", None), + None, None, "Auto", "heat", None, None, None), DemoClimate("Hvac", 21, TEMP_CELSIUS, True, 22, "On High", - 67, 54, "Off", "Cool", False), + 67, 54, "Off", "cool", False, None, None), + DemoClimate("Ecobee", 23, TEMP_CELSIUS, None, 23, "Auto Low", + None, None, "Auto", "auto", None, 24, 21) ]) @@ -26,7 +29,7 @@ class DemoClimate(ClimateDevice): def __init__(self, name, target_temperature, unit_of_measurement, away, current_temperature, current_fan_mode, target_humidity, current_humidity, current_swing_mode, - current_operation, aux): + current_operation, aux, target_temp_high, target_temp_low): """Initialize the climate device.""" self._name = name self._target_temperature = target_temperature @@ -40,8 +43,10 @@ class DemoClimate(ClimateDevice): self._aux = aux self._current_swing_mode = current_swing_mode self._fan_list = ["On Low", "On High", "Auto Low", "Auto High", "Off"] - self._operation_list = ["Heat", "Cool", "Auto Changeover", "Off"] + self._operation_list = ["heat", "cool", "auto", "off"] self._swing_list = ["Auto", "1", "2", "3", "Off"] + self._target_temperature_high = target_temp_high + self._target_temperature_low = target_temp_low @property def should_poll(self): @@ -68,6 +73,16 @@ class DemoClimate(ClimateDevice): """Return the temperature we try to reach.""" return self._target_temperature + @property + def target_temperature_high(self): + """Return the highbound target temperature we try to reach.""" + return self._target_temperature_high + + @property + def target_temperature_low(self): + """Return the lowbound target temperature we try to reach.""" + return self._target_temperature_low + @property def current_humidity(self): """Return the current humidity.""" @@ -108,9 +123,14 @@ class DemoClimate(ClimateDevice): """List of available fan modes.""" return self._fan_list - def set_temperature(self, temperature): - """Set new target temperature.""" - self._target_temperature = temperature + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + if kwargs.get(ATTR_TEMPERATURE) is not None: + self._target_temperature = kwargs.get(ATTR_TEMPERATURE) + if kwargs.get(ATTR_TARGET_TEMP_HIGH) is not None and \ + kwargs.get(ATTR_TARGET_TEMP_LOW) is not None: + self._target_temperature_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + self._target_temperature_low = kwargs.get(ATTR_TARGET_TEMP_LOW) self.update_ha_state() def set_humidity(self, humidity): diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py index 2417a8562ce..5d78aeb8597 100644 --- a/homeassistant/components/climate/ecobee.py +++ b/homeassistant/components/climate/ecobee.py @@ -6,23 +6,27 @@ https://home-assistant.io/components/climate.ecobee/ """ import logging from os import path + import voluptuous as vol from homeassistant.components import ecobee from homeassistant.components.climate import ( - DOMAIN, STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice) + DOMAIN, STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice, + ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH) from homeassistant.const import ( - ATTR_ENTITY_ID, STATE_OFF, STATE_ON, TEMP_FAHRENHEIT) + ATTR_ENTITY_ID, STATE_OFF, STATE_ON, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv -DEPENDENCIES = ['ecobee'] -_LOGGER = logging.getLogger(__name__) -ECOBEE_CONFIG_FILE = 'ecobee.conf' _CONFIGURING = {} +_LOGGER = logging.getLogger(__name__) + +ATTR_FAN_MIN_ON_TIME = 'fan_min_on_time' + +DEPENDENCIES = ['ecobee'] + +SERVICE_SET_FAN_MIN_ON_TIME = 'ecobee_set_fan_min_on_time' -ATTR_FAN_MIN_ON_TIME = "fan_min_on_time" -SERVICE_SET_FAN_MIN_ON_TIME = "ecobee_set_fan_min_on_time" SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_FAN_MIN_ON_TIME): vol.Coerce(int), @@ -142,7 +146,11 @@ class Thermostat(ClimateDevice): @property def current_operation(self): """Return current operation.""" - return self.operation_mode + if self.operation_mode == 'auxHeatOnly' or \ + self.operation_mode == 'heatPump': + return STATE_HEAT + else: + return self.operation_mode @property def operation_list(self): @@ -211,11 +219,17 @@ class Thermostat(ClimateDevice): """Turn away off.""" self.data.ecobee.resume_program(self.thermostat_index) - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" - temperature = int(temperature) - low_temp = temperature - 1 - high_temp = temperature + 1 + if kwargs.get(ATTR_TEMPERATURE) is not None: + temperature = kwargs.get(ATTR_TEMPERATURE) + low_temp = temperature - 1 + high_temp = temperature + 1 + if kwargs.get(ATTR_TARGET_TEMP_LOW) is not None and \ + kwargs.get(ATTR_TARGET_TEMP_HIGH) is not None: + low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) + high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) + if self.hold_temp: self.data.ecobee.set_hold_temp(self.thermostat_index, low_temp, high_temp, "indefinite") diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index 01114972811..646bf7f2aa8 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -7,14 +7,12 @@ https://home-assistant.io/components/climate.eq3btsmart/ import logging from homeassistant.components.climate import ClimateDevice -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, CONF_DEVICES, ATTR_TEMPERATURE from homeassistant.util.temperature import convert REQUIREMENTS = ['bluepy_devices==0.2.0'] CONF_MAC = 'mac' -CONF_DEVICES = 'devices' -CONF_ID = 'id' _LOGGER = logging.getLogger(__name__) @@ -28,7 +26,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices.append(EQ3BTSmartThermostat(mac, name)) add_devices(devices) - return True # pylint: disable=too-many-instance-attributes, import-error, abstract-method @@ -63,8 +60,11 @@ class EQ3BTSmartThermostat(ClimateDevice): """Return the temperature we try to reach.""" return self._thermostat.target_temperature - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return self._thermostat.target_temperature = temperature @property diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index 11e6707ad47..fd85d7fd46b 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -11,7 +11,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components import switch from homeassistant.components.climate import ( STATE_HEAT, STATE_COOL, STATE_IDLE, ClimateDevice) -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF, ATTR_TEMPERATURE) from homeassistant.helpers import condition from homeassistant.helpers.event import track_state_change @@ -123,8 +124,11 @@ class GenericThermostat(ClimateDevice): """Return the temperature we try to reach.""" return self._target_temp - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return self._target_temp = temperature self._control_heating() self.update_ha_state() diff --git a/homeassistant/components/climate/heatmiser.py b/homeassistant/components/climate/heatmiser.py index c7dd5534f57..941f211c411 100644 --- a/homeassistant/components/climate/heatmiser.py +++ b/homeassistant/components/climate/heatmiser.py @@ -10,7 +10,7 @@ https://home-assistant.io/components/climate.heatmiser/ import logging from homeassistant.components.climate import ClimateDevice -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE CONF_IPADDRESS = 'ipaddress' CONF_PORT = 'port' @@ -98,16 +98,18 @@ class HeatmiserV3Thermostat(ClimateDevice): """Return the temperature we try to reach.""" return self._target_temperature - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" - temperature = int(temperature) + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return self.heatmiser.hmSendAddress( self._id, 18, temperature, 1, self.serport) - self._target_temperature = int(temperature) + self._target_temperature = temperature def update(self): """Get the latest data.""" diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py index e51ad5e67a5..7e0b4fd6450 100644 --- a/homeassistant/components/climate/homematic.py +++ b/homeassistant/components/climate/homematic.py @@ -8,7 +8,7 @@ import logging import homeassistant.components.homematic as homematic from homeassistant.components.climate import ClimateDevice, STATE_AUTO from homeassistant.util.temperature import convert -from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN +from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN, ATTR_TEMPERATURE DEPENDENCIES = ['homematic'] @@ -29,9 +29,11 @@ def setup_platform(hass, config, add_callback_devices, discovery_info=None): if discovery_info is None: return - return homematic.setup_hmdevice_discovery_helper(HMThermostat, - discovery_info, - add_callback_devices) + return homematic.setup_hmdevice_discovery_helper( + HMThermostat, + discovery_info, + add_callback_devices + ) # pylint: disable=abstract-method @@ -90,10 +92,13 @@ class HMThermostat(homematic.HMDevice, ClimateDevice): return None return self._data.get('SET_TEMPERATURE', None) - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) if not self.available: return None + if temperature is None: + return self._hmdevice.set_temperature(temperature) def set_operation_mode(self, operation_mode): @@ -113,26 +118,8 @@ class HMThermostat(homematic.HMDevice, ClimateDevice): """Return the maximum temperature - 30.5 means on.""" return convert(30.5, TEMP_CELSIUS, self.unit_of_measurement) - def _check_hm_to_ha_object(self): - """Check if possible to use the Homematic object as this HA type.""" - from pyhomematic.devicetypes.thermostats import HMThermostat\ - as pyHMThermostat - - # Check compatibility from HMDevice - if not super()._check_hm_to_ha_object(): - return False - - # Check if the Homematic device correct for this HA device - if isinstance(self._hmdevice, pyHMThermostat): - return True - - _LOGGER.critical("This %s can't be use as thermostat", self._name) - return False - def _init_data_struct(self): """Generate a data dict (self._data) from the Homematic metadata.""" - super()._init_data_struct() - # Add state to data dict self._data.update({"CONTROL_MODE": STATE_UNKNOWN, "SET_TEMPERATURE": STATE_UNKNOWN, diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index 1efce2b95de..001bf8806ac 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -9,7 +9,8 @@ import socket from homeassistant.components.climate import ClimateDevice from homeassistant.const import ( - CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT) + CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, + ATTR_TEMPERATURE) REQUIREMENTS = ['evohomeclient==0.2.5', 'somecomfort==0.2.1'] @@ -132,8 +133,11 @@ class RoundThermostat(ClimateDevice): return None return self._target_temperature - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return self.device.set_temperature(self._name, temperature) @property @@ -234,8 +238,11 @@ class HoneywellUSThermostat(ClimateDevice): """Return current operation ie. heat, cool, idle.""" return getattr(self._device, 'system_mode', None) - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return import somecomfort try: if self._device.system_mode == 'cool': diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index 10f02d80cc7..a9d4358a059 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/knx/ import logging from homeassistant.components.climate import ClimateDevice -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE from homeassistant.components.knx import ( KNXConfig, KNXMultiAddressDevice) @@ -71,8 +71,11 @@ class KNXThermostat(KNXMultiAddressDevice, ClimateDevice): return knx2_to_float(self.value("setpoint")) - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return from knxip.conversion import float_to_knx2 self.set_value("setpoint", float_to_knx2(temperature)) diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py index 39746bff601..f55d1d856eb 100644 --- a/homeassistant/components/climate/nest.py +++ b/homeassistant/components/climate/nest.py @@ -8,13 +8,13 @@ import voluptuous as vol import homeassistant.components.nest as nest from homeassistant.components.climate import ( - STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice) -from homeassistant.const import TEMP_CELSIUS, CONF_PLATFORM, CONF_SCAN_INTERVAL + STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + TEMP_CELSIUS, CONF_SCAN_INTERVAL, ATTR_TEMPERATURE) DEPENDENCIES = ['nest'] -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): nest.DOMAIN, +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SCAN_INTERVAL): vol.All(vol.Coerce(int), vol.Range(min=1)), }) @@ -132,8 +132,11 @@ class NestThermostat(ClimateDevice): """Return if away mode is on.""" return self.structure.away - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return if self.device.mode == 'range': if self.target_temperature == self.target_temperature_low: temperature = (temperature, self.target_temperature_high) diff --git a/homeassistant/components/climate/proliphix.py b/homeassistant/components/climate/proliphix.py index c6e8ed69617..fa2230fba55 100644 --- a/homeassistant/components/climate/proliphix.py +++ b/homeassistant/components/climate/proliphix.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/climate.proliphix/ from homeassistant.components.climate import ( STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice) from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME, TEMP_FAHRENHEIT) + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) REQUIREMENTS = ['proliphix==0.3.1'] @@ -85,6 +85,9 @@ class ProliphixThermostat(ClimateDevice): elif state == 6: return STATE_COOL - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return self._pdp.setback = temperature diff --git a/homeassistant/components/climate/radiotherm.py b/homeassistant/components/climate/radiotherm.py index deee3d53f3f..90611ce20b2 100644 --- a/homeassistant/components/climate/radiotherm.py +++ b/homeassistant/components/climate/radiotherm.py @@ -11,7 +11,7 @@ from urllib.error import URLError from homeassistant.components.climate import ( STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_IDLE, STATE_OFF, ClimateDevice) -from homeassistant.const import CONF_HOST, TEMP_FAHRENHEIT +from homeassistant.const import CONF_HOST, TEMP_FAHRENHEIT, ATTR_TEMPERATURE REQUIREMENTS = ['radiotherm==1.2'] HOLD_TEMP = 'hold_temp' @@ -107,8 +107,11 @@ class RadioThermostat(ClimateDevice): else: self._current_operation = STATE_IDLE - def set_temperature(self, temperature): + def set_temperature(self, **kwargs): """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return if self._current_operation == STATE_COOL: self.device.t_cool = temperature elif self._current_operation == STATE_HEAT: diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index 530e3ea028f..0ba85105c18 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -12,7 +12,8 @@ from homeassistant.components.climate import ClimateDevice from homeassistant.components.zwave import ( ATTR_NODE_ID, ATTR_VALUE_ID, ZWaveDeviceEntity) from homeassistant.components import zwave -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import ( + TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) _LOGGER = logging.getLogger(__name__) @@ -23,6 +24,10 @@ REMOTEC = 0x5254 REMOTEC_ZXT_120 = 0x8377 REMOTEC_ZXT_120_THERMOSTAT = (REMOTEC, REMOTEC_ZXT_120) +HORSTMANN = 0x0059 +HORSTMANN_HRT4_ZW = 0x3 +HORSTMANN_HRT4_ZW_THERMOSTAT = (HORSTMANN, HORSTMANN_HRT4_ZW) + COMMAND_CLASS_SENSOR_MULTILEVEL = 0x31 COMMAND_CLASS_THERMOSTAT_MODE = 0x40 COMMAND_CLASS_THERMOSTAT_SETPOINT = 0x43 @@ -30,9 +35,11 @@ COMMAND_CLASS_THERMOSTAT_FAN_MODE = 0x44 COMMAND_CLASS_CONFIGURATION = 0x70 WORKAROUND_ZXT_120 = 'zxt_120' +WORKAROUND_HRT4_ZW = 'hrt4_zw' DEVICE_MAPPINGS = { - REMOTEC_ZXT_120_THERMOSTAT: WORKAROUND_ZXT_120 + REMOTEC_ZXT_120_THERMOSTAT: WORKAROUND_ZXT_120, + HORSTMANN_HRT4_ZW_THERMOSTAT: WORKAROUND_HRT4_ZW } SET_TEMP_TO_INDEX = { @@ -63,6 +70,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): node = zwave.NETWORK.nodes[discovery_info[ATTR_NODE_ID]] value = node.values[discovery_info[ATTR_VALUE_ID]] value.set_change_verified(False) + if value.index != 1: # Only add 1 device + return add_devices([ZWaveClimate(value, temp_unit)]) _LOGGER.debug("discovery_info=%s and zwave.NETWORK=%s", discovery_info, zwave.NETWORK) @@ -88,8 +97,10 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): self._current_swing_mode = None self._swing_list = None self._unit = temp_unit + self._index_operation = None _LOGGER.debug("temp_unit is %s", self._unit) self._zxt_120 = None + self._hrt4_zw = None self.update_properties() # register listener dispatcher.connect( @@ -99,12 +110,15 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): value.node.product_id.strip()): specific_sensor_key = (int(value.node.manufacturer_id, 16), int(value.node.product_id, 16)) - if specific_sensor_key in DEVICE_MAPPINGS: if DEVICE_MAPPINGS[specific_sensor_key] == WORKAROUND_ZXT_120: _LOGGER.debug("Remotec ZXT-120 Zwave Thermostat" " workaround") self._zxt_120 = 1 + if DEVICE_MAPPINGS[specific_sensor_key] == WORKAROUND_HRT4_ZW: + _LOGGER.debug("Horstmann HRT4-ZW Zwave Thermostat" + " workaround") + self._hrt4_zw = 1 def value_changed(self, value): """Called when a value has changed on the network.""" @@ -120,6 +134,8 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): for value in self._node.get_values( class_id=COMMAND_CLASS_THERMOSTAT_MODE).values(): self._current_operation = value.data + self._index_operation = SET_TEMP_TO_INDEX.get( + self._current_operation) self._operation_list = list(value.data_items) _LOGGER.debug("self._operation_list=%s", self._operation_list) _LOGGER.debug("self._current_operation=%s", @@ -153,11 +169,14 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).values(): if self.current_operation is not None and \ self.current_operation != 'Off': - if SET_TEMP_TO_INDEX.get(self._current_operation) \ - != value.index: + if self._index_operation != value.index: continue if self._zxt_120: - continue + break + self._target_temperature = int(value.data) + break + _LOGGER.debug("Device can't set setpoint based on operation mode." + " Defaulting to index=1") self._target_temperature = int(value.data) @property @@ -215,28 +234,48 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): """Return the temperature we try to reach.""" return self._target_temperature - def set_temperature(self, temperature): +# pylint: disable=too-many-branches, too-many-statements + def set_temperature(self, **kwargs): """Set new target temperature.""" + if kwargs.get(ATTR_TEMPERATURE) is not None: + temperature = kwargs.get(ATTR_TEMPERATURE) + else: + return + for value in self._node.get_values( class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).values(): if self.current_operation is not None: - if SET_TEMP_TO_INDEX.get(self._current_operation) \ - != value.index: + if self._hrt4_zw and self.current_operation == 'Off': + # HRT4-ZW can change setpoint when off. + value.data = int(temperature) + if self._index_operation != value.index: continue - _LOGGER.debug("SET_TEMP_TO_INDEX=%s and" + _LOGGER.debug("self._index_operation=%s and" " self._current_operation=%s", - SET_TEMP_TO_INDEX.get(self._current_operation), + self._index_operation, self._current_operation) if self._zxt_120: + _LOGGER.debug("zxt_120: Setting new setpoint for %s, " + " operation=%s, temp=%s", + self._index_operation, + self._current_operation, temperature) # ZXT-120 does not support get setpoint self._target_temperature = temperature # ZXT-120 responds only to whole int - value.data = int(round(temperature, 0)) + value.data = round(temperature, 0) + break else: - value.data = int(temperature) - break + _LOGGER.debug("Setting new setpoint for %s, " + "operation=%s, temp=%s", + self._index_operation, + self._current_operation, temperature) + value.data = temperature + break else: - value.data = int(temperature) + _LOGGER.debug("Setting new setpoint for no known " + "operation mode. Index=1 and " + "temperature=%s", temperature) + value.data = temperature break def set_fan_mode(self, fan): diff --git a/homeassistant/components/cover/command_line.py b/homeassistant/components/cover/command_line.py index c2c8050f09f..0a1da9d7a20 100644 --- a/homeassistant/components/cover/command_line.py +++ b/homeassistant/components/cover/command_line.py @@ -7,29 +7,54 @@ https://home-assistant.io/components/cover.command_line/ import logging import subprocess -from homeassistant.components.cover import CoverDevice -from homeassistant.const import CONF_VALUE_TEMPLATE +import voluptuous as vol + +from homeassistant.components.cover import (CoverDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_COMMAND_CLOSE, CONF_COMMAND_OPEN, CONF_COMMAND_STATE, + CONF_COMMAND_STOP, CONF_COVERS, CONF_VALUE_TEMPLATE, CONF_FRIENDLY_NAME) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers import template _LOGGER = logging.getLogger(__name__) +COVER_SCHEMA = vol.Schema({ + vol.Optional(CONF_COMMAND_CLOSE, default='true'): cv.string, + vol.Optional(CONF_COMMAND_OPEN, default='true'): cv.string, + vol.Optional(CONF_COMMAND_STATE): cv.string, + vol.Optional(CONF_COMMAND_STOP, default='true'): cv.string, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE, default='{{ value }}'): cv.template, +}) -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_COVERS): vol.Schema({cv.slug: COVER_SCHEMA}), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup cover controlled by shell commands.""" - covers = config.get('covers', {}) - devices = [] + devices = config.get(CONF_COVERS, {}) + covers = [] - for dev_name, properties in covers.items(): - devices.append( + for device_name, device_config in devices.items(): + covers.append( CommandCover( hass, - properties.get('name', dev_name), - properties.get('opencmd', 'true'), - properties.get('closecmd', 'true'), - properties.get('stopcmd', 'true'), - properties.get('statecmd', False), - properties.get(CONF_VALUE_TEMPLATE, '{{ value }}'))) - add_devices_callback(devices) + device_config.get(CONF_FRIENDLY_NAME, device_name), + device_config.get(CONF_COMMAND_OPEN), + device_config.get(CONF_COMMAND_CLOSE), + device_config.get(CONF_COMMAND_STOP), + device_config.get(CONF_COMMAND_STATE), + device_config.get(CONF_VALUE_TEMPLATE), + ) + ) + + if not covers: + _LOGGER.error("No covers added") + return False + + add_devices(covers) # pylint: disable=too-many-arguments, too-many-instance-attributes diff --git a/homeassistant/components/cover/demo.py b/homeassistant/components/cover/demo.py index 1f1c666f339..acddfcf7c73 100644 --- a/homeassistant/components/cover/demo.py +++ b/homeassistant/components/cover/demo.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ from homeassistant.components.cover import CoverDevice -from homeassistant.const import EVENT_TIME_CHANGED from homeassistant.helpers.event import track_utc_time_change @@ -32,8 +31,8 @@ class DemoCover(CoverDevice): self._tilt_position = tilt_position self._closing = True self._closing_tilt = True - self._listener_cover = None - self._listener_cover_tilt = None + self._unsub_listener_cover = None + self._unsub_listener_cover_tilt = None @property def name(self): @@ -120,10 +119,9 @@ class DemoCover(CoverDevice): """Stop the cover.""" if self._position is None: return - if self._listener_cover is not None: - self.hass.bus.remove_listener(EVENT_TIME_CHANGED, - self._listener_cover) - self._listener_cover = None + if self._unsub_listener_cover is not None: + self._unsub_listener_cover() + self._unsub_listener_cover = None self._set_position = None def stop_cover_tilt(self, **kwargs): @@ -131,16 +129,15 @@ class DemoCover(CoverDevice): if self._tilt_position is None: return - if self._listener_cover_tilt is not None: - self.hass.bus.remove_listener(EVENT_TIME_CHANGED, - self._listener_cover_tilt) - self._listener_cover_tilt = None + if self._unsub_listener_cover_tilt is not None: + self._unsub_listener_cover_tilt() + self._unsub_listener_cover_tilt = None self._set_tilt_position = None def _listen_cover(self): """Listen for changes in cover.""" - if self._listener_cover is None: - self._listener_cover = track_utc_time_change( + if self._unsub_listener_cover is None: + self._unsub_listener_cover = track_utc_time_change( self.hass, self._time_changed_cover) def _time_changed_cover(self, now): @@ -156,8 +153,8 @@ class DemoCover(CoverDevice): def _listen_cover_tilt(self): """Listen for changes in cover tilt.""" - if self._listener_cover_tilt is None: - self._listener_cover_tilt = track_utc_time_change( + if self._unsub_listener_cover_tilt is None: + self._unsub_listener_cover_tilt = track_utc_time_change( self.hass, self._time_changed_cover_tilt) def _time_changed_cover_tilt(self, now): diff --git a/homeassistant/components/cover/homematic.py b/homeassistant/components/cover/homematic.py index fd68ac3d265..aea05a9160a 100644 --- a/homeassistant/components/cover/homematic.py +++ b/homeassistant/components/cover/homematic.py @@ -24,9 +24,11 @@ def setup_platform(hass, config, add_callback_devices, discovery_info=None): if discovery_info is None: return - return homematic.setup_hmdevice_discovery_helper(HMCover, - discovery_info, - add_callback_devices) + return homematic.setup_hmdevice_discovery_helper( + HMCover, + discovery_info, + add_callback_devices + ) # pylint: disable=abstract-method @@ -77,25 +79,8 @@ class HMCover(homematic.HMDevice, CoverDevice): if self.available: self._hmdevice.stop(self._channel) - def _check_hm_to_ha_object(self): - """Check if possible to use the HM Object as this HA type.""" - from pyhomematic.devicetypes.actors import Blind - - # Check compatibility from HMDevice - if not super()._check_hm_to_ha_object(): - return False - - # Check if the homematic device is correct for this HA device - if isinstance(self._hmdevice, Blind): - return True - - _LOGGER.critical("This %s can't be use as cover!", self._name) - return False - def _init_data_struct(self): """Generate a data dict (self._data) from hm metadata.""" - super()._init_data_struct() - # Add state to data dict self._state = "LEVEL" self._data.update({self._state: STATE_UNKNOWN}) diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 5695bc5005a..a2eb40e21e8 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -19,13 +19,13 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ 'binary_sensor', 'camera', 'climate', + 'cover', 'device_tracker', - 'garage_door', + 'fan', 'light', 'lock', 'media_player', 'notify', - 'rollershutter', 'sensor', 'switch', ] diff --git a/homeassistant/components/device_sun_light_trigger.py b/homeassistant/components/device_sun_light_trigger.py index a954ae8fd0f..1bf921c2e06 100644 --- a/homeassistant/components/device_sun_light_trigger.py +++ b/homeassistant/components/device_sun_light_trigger.py @@ -7,23 +7,38 @@ https://home-assistant.io/components/device_sun_light_trigger/ import logging from datetime import timedelta +import voluptuous as vol + import homeassistant.util.dt as dt_util from homeassistant.const import STATE_HOME, STATE_NOT_HOME from homeassistant.helpers.event import track_point_in_time from homeassistant.helpers.event_decorators import track_state_change from homeassistant.loader import get_component +import homeassistant.helpers.config_validation as cv -DOMAIN = "device_sun_light_trigger" +DOMAIN = 'device_sun_light_trigger' DEPENDENCIES = ['light', 'device_tracker', 'group', 'sun'] +CONF_DEVICE_GROUP = 'device_group' +CONF_DISABLE_TURN_OFF = 'disable_turn_off' +CONF_LIGHT_GROUP = 'light_group' +CONF_LIGHT_PROFILE = 'light_profile' + +DEFAULT_DISABLE_TURN_OFF = False +DEFAULT_LIGHT_PROFILE = 'relax' + LIGHT_TRANSITION_TIME = timedelta(minutes=15) -# Light profile to be used if none given -LIGHT_PROFILE = 'relax' - -CONF_LIGHT_PROFILE = 'light_profile' -CONF_LIGHT_GROUP = 'light_group' -CONF_DEVICE_GROUP = 'device_group' +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_DEVICE_GROUP): cv.entity_id, + vol.Optional(CONF_DISABLE_TURN_OFF, default=DEFAULT_DISABLE_TURN_OFF): + cv.boolean, + vol.Optional(CONF_LIGHT_GROUP): cv.string, + vol.Optional(CONF_LIGHT_PROFILE, default=DEFAULT_LIGHT_PROFILE): + cv.string, + }), +}, extra=vol.ALLOW_EXTRA) # pylint: disable=too-many-locals @@ -35,10 +50,10 @@ def setup(hass, config): light = get_component('light') sun = get_component('sun') - disable_turn_off = 'disable_turn_off' in config[DOMAIN] + disable_turn_off = config[DOMAIN].get(CONF_DISABLE_TURN_OFF) light_group = config[DOMAIN].get(CONF_LIGHT_GROUP, light.ENTITY_ID_ALL_LIGHTS) - light_profile = config[DOMAIN].get(CONF_LIGHT_PROFILE, LIGHT_PROFILE) + light_profile = config[DOMAIN].get(CONF_LIGHT_PROFILE) device_group = config[DOMAIN].get(CONF_DEVICE_GROUP, device_tracker.ENTITY_ID_ALL_DEVICES) device_entity_ids = group.get_entity_ids(hass, device_group, @@ -52,7 +67,7 @@ def setup(hass, config): light_ids = group.get_entity_ids(hass, light_group, light.DOMAIN) if not light_ids: - logger.error("No lights found to turn on ") + logger.error("No lights found to turn on") return False def calc_time_for_light_when_sunset(): diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index a4f65ab4ea4..4247213087b 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -62,6 +62,7 @@ ATTR_HOST_NAME = 'host_name' ATTR_LOCATION_NAME = 'location_name' ATTR_GPS = 'gps' ATTR_BATTERY = 'battery' +ATTR_ATTRIBUTES = 'attributes' PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SCAN_INTERVAL): cv.positive_int, # seconds @@ -86,10 +87,11 @@ def is_on(hass: HomeAssistantType, entity_id: str=None): return hass.states.is_state(entity, STATE_HOME) +# pylint: disable=too-many-arguments def see(hass: HomeAssistantType, mac: str=None, dev_id: str=None, host_name: str=None, location_name: str=None, gps: GPSType=None, gps_accuracy=None, - battery=None): # pylint: disable=too-many-arguments + battery=None, attributes: dict=None): """Call service to notify you see device.""" data = {key: value for key, value in ((ATTR_MAC, mac), @@ -99,6 +101,9 @@ def see(hass: HomeAssistantType, mac: str=None, dev_id: str=None, (ATTR_GPS, gps), (ATTR_GPS_ACCURACY, gps_accuracy), (ATTR_BATTERY, battery)) if value is not None} + if attributes: + for key, value in attributes: + data[key] = value hass.services.call(DOMAIN, SERVICE_SEE, data) @@ -164,7 +169,7 @@ def setup(hass: HomeAssistantType, config: ConfigType): """Service to see a device.""" args = {key: value for key, value in call.data.items() if key in (ATTR_MAC, ATTR_DEV_ID, ATTR_HOST_NAME, ATTR_LOCATION_NAME, - ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_BATTERY)} + ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_BATTERY, ATTR_ATTRIBUTES)} tracker.see(**args) descriptions = load_yaml_config_file( @@ -202,7 +207,7 @@ class DeviceTracker(object): def see(self, mac: str=None, dev_id: str=None, host_name: str=None, location_name: str=None, gps: GPSType=None, gps_accuracy=None, - battery: str=None): + battery: str=None, attributes: dict=None): """Notify the device tracker that you see a device.""" with self.lock: if mac is None and dev_id is None: @@ -218,7 +223,7 @@ class DeviceTracker(object): if device: device.seen(host_name, location_name, gps, gps_accuracy, - battery) + battery, attributes) if device.track: device.update_ha_state() return @@ -232,7 +237,8 @@ class DeviceTracker(object): if mac is not None: self.mac_to_dev[mac] = device - device.seen(host_name, location_name, gps, gps_accuracy, battery) + device.seen(host_name, location_name, gps, gps_accuracy, battery, + attributes) if device.track: device.update_ha_state() @@ -267,6 +273,7 @@ class Device(Entity): gps_accuracy = 0 last_seen = None # type: dt_util.dt.datetime battery = None # type: str + attributes = None # type: dict # Track if the last update of this device was HOME. last_update_home = False @@ -330,6 +337,10 @@ class Device(Entity): if self.battery: attr[ATTR_BATTERY] = self.battery + if self.attributes: + for key, value in self.attributes: + attr[key] = value + return attr @property @@ -338,13 +349,15 @@ class Device(Entity): return self.away_hide and self.state != STATE_HOME def seen(self, host_name: str=None, location_name: str=None, - gps: GPSType=None, gps_accuracy=0, battery: str=None): + gps: GPSType=None, gps_accuracy=0, battery: str=None, + attributes: dict=None): """Mark the device as seen.""" self.last_seen = dt_util.utcnow() self.host_name = host_name self.location_name = location_name self.gps_accuracy = gps_accuracy or 0 self.battery = battery + self.attributes = attributes self.gps = None if gps is not None: try: diff --git a/homeassistant/components/device_tracker/aruba.py b/homeassistant/components/device_tracker/aruba.py index a62306b5619..6383bc962a4 100644 --- a/homeassistant/components/device_tracker/aruba.py +++ b/homeassistant/components/device_tracker/aruba.py @@ -9,9 +9,11 @@ import re import threading from datetime import timedelta -from homeassistant.components.device_tracker import DOMAIN +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import validate_config from homeassistant.util import Throttle # Return cached results if last scan was less then this time ago @@ -25,15 +27,16 @@ _DEVICES_REGEX = re.compile( r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' + r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s+') +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string +}) + # pylint: disable=unused-argument def get_scanner(hass, config): """Validate the configuration and return a Aruba scanner.""" - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, - _LOGGER): - return None - scanner = ArubaDeviceScanner(config[DOMAIN]) return scanner if scanner.success_init else None @@ -90,7 +93,7 @@ class ArubaDeviceScanner(object): def get_aruba_data(self): """Retrieve data from Aruba Access Point and return parsed result.""" import pexpect - connect = "ssh {}@{}" + connect = 'ssh {}@{}' ssh = pexpect.spawn(connect.format(self.username, self.host)) query = ssh.expect(['password:', pexpect.TIMEOUT, pexpect.EOF, 'continue connecting (yes/no)?', @@ -98,22 +101,22 @@ class ArubaDeviceScanner(object): 'Connection refused', 'Connection timed out'], timeout=120) if query == 1: - _LOGGER.error("Timeout") + _LOGGER.error('Timeout') return elif query == 2: - _LOGGER.error("Unexpected response from router") + _LOGGER.error('Unexpected response from router') return elif query == 3: ssh.sendline('yes') ssh.expect('password:') elif query == 4: - _LOGGER.error("Host key Changed") + _LOGGER.error('Host key Changed') return elif query == 5: - _LOGGER.error("Connection refused by server") + _LOGGER.error('Connection refused by server') return elif query == 6: - _LOGGER.error("Connection timed out") + _LOGGER.error('Connection timed out') return ssh.sendline(self.password) ssh.expect('#') diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index a125607a00f..4fd2771db4f 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -26,19 +26,20 @@ CONF_PROTOCOL = 'protocol' CONF_MODE = 'mode' CONF_SSH_KEY = 'ssh_key' CONF_PUB_KEY = 'pub_key' +SECRET_GROUP = 'Password or SSH Key' PLATFORM_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_PASSWORD, CONF_PUB_KEY, CONF_SSH_KEY), PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, vol.Optional(CONF_PROTOCOL, default='ssh'): vol.In(['ssh', 'telnet']), vol.Optional(CONF_MODE, default='router'): vol.In(['router', 'ap']), - vol.Optional(CONF_SSH_KEY): cv.isfile, - vol.Optional(CONF_PUB_KEY): cv.isfile + vol.Exclusive(CONF_PASSWORD, SECRET_GROUP): cv.string, + vol.Exclusive(CONF_SSH_KEY, SECRET_GROUP): cv.isfile, + vol.Exclusive(CONF_PUB_KEY, SECRET_GROUP): cv.isfile })) @@ -101,6 +102,21 @@ class AsusWrtDeviceScanner(object): self.protocol = config[CONF_PROTOCOL] self.mode = config[CONF_MODE] + if self.protocol == 'ssh': + if self.ssh_key: + self.ssh_secret = {'ssh_key': self.ssh_key} + elif self.password: + self.ssh_secret = {'password': self.password} + else: + _LOGGER.error('No password or private key specified') + self.success_init = False + return + else: + if not self.password: + _LOGGER.error('No password specified') + self.success_init = False + return + self.lock = threading.Lock() self.last_results = {} @@ -149,15 +165,17 @@ class AsusWrtDeviceScanner(object): """Retrieve data from ASUSWRT via the ssh protocol.""" from pexpect import pxssh, exceptions + ssh = pxssh.pxssh() + try: + ssh.login(self.host, self.username, **self.ssh_secret) + except exceptions.EOF as err: + _LOGGER.error('Connection refused. Is SSH enabled?') + return None + except pxssh.ExceptionPxssh as err: + _LOGGER.error('Unable to connect via SSH: %s', str(err)) + return None + try: - ssh = pxssh.pxssh() - if self.ssh_key: - ssh.login(self.host, self.username, ssh_key=self.ssh_key) - elif self.password: - ssh.login(self.host, self.username, self.password) - else: - _LOGGER.error('No password or private key specified') - return None ssh.sendline(_IP_NEIGH_CMD) ssh.prompt() neighbors = ssh.before.split(b'\n')[1:-1] @@ -178,9 +196,6 @@ class AsusWrtDeviceScanner(object): except pxssh.ExceptionPxssh as exc: _LOGGER.error('Unexpected response from router: %s', exc) return None - except exceptions.EOF: - _LOGGER.error('Connection refused or no route to host') - return None def telnet_connection(self): """Retrieve data from ASUSWRT via the telnet protocol.""" diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py new file mode 100644 index 00000000000..927c515b3a5 --- /dev/null +++ b/homeassistant/components/device_tracker/automatic.py @@ -0,0 +1,161 @@ +""" +Support for the Automatic platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.automatic/ +""" +from datetime import timedelta +import logging +import re +import requests + +import voluptuous as vol + +from homeassistant.components.device_tracker import (PLATFORM_SCHEMA, + ATTR_ATTRIBUTES) +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle, datetime as dt_util + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30) + +CONF_CLIENT_ID = 'client_id' +CONF_SECRET = 'secret' +CONF_DEVICES = 'devices' + +SCOPE = 'scope:location scope:vehicle:profile scope:user:profile scope:trip' + +ATTR_ACCESS_TOKEN = 'access_token' +ATTR_EXPIRES_IN = 'expires_in' +ATTR_RESULTS = 'results' +ATTR_VEHICLE = 'vehicle' +ATTR_ENDED_AT = 'ended_at' +ATTR_END_LOCATION = 'end_location' + +URL_AUTHORIZE = 'https://accounts.automatic.com/oauth/access_token/' +URL_VEHICLES = 'https://api.automatic.com/vehicle/' +URL_TRIPS = 'https://api.automatic.com/trip/' + +_VEHICLE_ID_REGEX = re.compile( + (URL_VEHICLES + '(.*)?[/]$').replace('/', r'\/')) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_SECRET): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_DEVICES): vol.All(cv.ensure_list, [cv.string]) +}) + + +def setup_scanner(hass, config: dict, see): + """Validate the configuration and return an Automatic scanner.""" + try: + AutomaticDeviceScanner(config, see) + except requests.HTTPError as err: + _LOGGER.error(str(err)) + return False + + return True + + +class AutomaticDeviceScanner(object): + """A class representing an Automatic device.""" + + def __init__(self, config: dict, see) -> None: + """Initialize the automatic device scanner.""" + self._devices = config.get(CONF_DEVICES, None) + self._access_token_payload = { + 'username': config.get(CONF_USERNAME), + 'password': config.get(CONF_PASSWORD), + 'client_id': config.get(CONF_CLIENT_ID), + 'client_secret': config.get(CONF_SECRET), + 'grant_type': 'password', + 'scope': SCOPE + } + self._headers = None + self._token_expires = dt_util.now() + self.last_results = {} + self.last_trips = {} + self.see = see + + self.scan_devices() + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return [item['id'] for item in self.last_results] + + def get_device_name(self, device): + """Get the device name from id.""" + vehicle = [item['display_name'] for item in self.last_results + if item['id'] == device] + + return vehicle[0] + + def _update_headers(self): + """Get the access token from automatic.""" + if self._headers is None or self._token_expires <= dt_util.now(): + resp = requests.post( + URL_AUTHORIZE, + data=self._access_token_payload) + + resp.raise_for_status() + + json = resp.json() + + access_token = json[ATTR_ACCESS_TOKEN] + self._token_expires = dt_util.now() + timedelta( + seconds=json[ATTR_EXPIRES_IN]) + self._headers = { + 'Authorization': 'Bearer {}'.format(access_token) + } + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self) -> None: + """Update the device info.""" + _LOGGER.info('Updating devices') + self._update_headers() + + response = requests.get(URL_VEHICLES, headers=self._headers) + + response.raise_for_status() + + self.last_results = [item for item in response.json()[ATTR_RESULTS] + if self._devices is None or item[ + 'display_name'] in self._devices] + + response = requests.get(URL_TRIPS, headers=self._headers) + + if response.status_code == 200: + for trip in response.json()[ATTR_RESULTS]: + vehicle_id = _VEHICLE_ID_REGEX.match( + trip[ATTR_VEHICLE]).group(1) + if vehicle_id not in self.last_trips: + self.last_trips[vehicle_id] = trip + elif self.last_trips[vehicle_id][ATTR_ENDED_AT] < trip[ + ATTR_ENDED_AT]: + self.last_trips[vehicle_id] = trip + + for vehicle in self.last_results: + dev_id = vehicle.get('id') + + attrs = { + 'fuel_level': vehicle.get('fuel_level_percent') + } + + kwargs = { + 'dev_id': dev_id, + 'mac': dev_id, + ATTR_ATTRIBUTES: attrs + } + + if dev_id in self.last_trips: + end_location = self.last_trips[dev_id][ATTR_END_LOCATION] + kwargs['gps'] = (end_location['lat'], end_location['lon']) + kwargs['gps_accuracy'] = end_location['accuracy_m'] + + self.see(**kwargs) diff --git a/homeassistant/components/device_tracker/bluetooth_le_tracker.py b/homeassistant/components/device_tracker/bluetooth_le_tracker.py index ce8a535ff57..9ee30dd0ce2 100644 --- a/homeassistant/components/device_tracker/bluetooth_le_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_le_tracker.py @@ -11,6 +11,7 @@ from homeassistant.components.device_tracker import ( DEFAULT_SCAN_INTERVAL, PLATFORM_SCHEMA, load_config, + DEFAULT_TRACK_NEW ) import homeassistant.util as util import homeassistant.util.dt as dt_util @@ -58,10 +59,13 @@ def setup_scanner(hass, config, see): def discover_ble_devices(): """Discover Bluetooth LE devices.""" _LOGGER.debug("Discovering Bluetooth LE devices") - service = DiscoveryService() - devices = service.discover(duration) - _LOGGER.debug("Bluetooth LE devices discovered = %s", devices) - + try: + service = DiscoveryService() + devices = service.discover(duration) + _LOGGER.debug("Bluetooth LE devices discovered = %s", devices) + except RuntimeError as error: + _LOGGER.error("Error during Bluetooth LE scan: %s", error) + devices = [] return devices yaml_path = hass.config.path(YAML_DEVICES) @@ -85,7 +89,7 @@ def setup_scanner(hass, config, see): # if track new devices is true discover new devices # on every scan. track_new = util.convert(config.get(CONF_TRACK_NEW), bool, - len(devs_to_track) == 0) + DEFAULT_TRACK_NEW) if not devs_to_track and not track_new: _LOGGER.warning("No Bluetooth LE devices to track!") return False diff --git a/homeassistant/components/device_tracker/bluetooth_tracker.py b/homeassistant/components/device_tracker/bluetooth_tracker.py index 298eddc4bc4..86e115c65c4 100644 --- a/homeassistant/components/device_tracker/bluetooth_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_tracker.py @@ -2,15 +2,13 @@ import logging from datetime import timedelta +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.components.device_tracker import ( - YAML_DEVICES, - CONF_TRACK_NEW, - CONF_SCAN_INTERVAL, - DEFAULT_SCAN_INTERVAL, - load_config, -) -import homeassistant.util as util + YAML_DEVICES, CONF_TRACK_NEW, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, + load_config, PLATFORM_SCHEMA, DEFAULT_TRACK_NEW) import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -19,6 +17,10 @@ REQUIREMENTS = ['pybluez==0.22'] BT_PREFIX = 'BT_' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_TRACK_NEW): cv.boolean +}) + def setup_scanner(hass, config, see): """Setup the Bluetooth Scanner.""" @@ -53,10 +55,8 @@ def setup_scanner(hass, config, see): else: devs_donot_track.append(device.mac[3:]) - # if track new devices is true discover new devices - # on startup. - track_new = util.convert(config.get(CONF_TRACK_NEW), bool, - len(devs_to_track) == 0) + # if track new devices is true discover new devices on startup. + track_new = config.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) if track_new: for dev in discover_devices(): if dev[0] not in devs_to_track and \ @@ -64,16 +64,16 @@ def setup_scanner(hass, config, see): devs_to_track.append(dev[0]) see_device(dev) - if not devs_to_track: - _LOGGER.warning("No bluetooth devices to track!") - return False - - interval = util.convert(config.get(CONF_SCAN_INTERVAL), int, - DEFAULT_SCAN_INTERVAL) + interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) def update_bluetooth(now): """Lookup bluetooth device and update status.""" try: + if track_new: + for dev in discover_devices(): + if dev[0] not in devs_to_track and \ + dev[0] not in devs_donot_track: + devs_to_track.append(dev[0]) for mac in devs_to_track: _LOGGER.debug("Scanning " + mac) result = bluetooth.lookup_name(mac, timeout=5) diff --git a/homeassistant/components/device_tracker/bt_home_hub_5.py b/homeassistant/components/device_tracker/bt_home_hub_5.py index c447fae1635..3b4115ff355 100644 --- a/homeassistant/components/device_tracker/bt_home_hub_5.py +++ b/homeassistant/components/device_tracker/bt_home_hub_5.py @@ -13,9 +13,10 @@ import json from urllib.parse import unquote import requests +import voluptuous as vol -from homeassistant.helpers import validate_config -from homeassistant.components.device_tracker import DOMAIN +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST from homeassistant.util import Throttle @@ -26,14 +27,14 @@ _LOGGER = logging.getLogger(__name__) _MAC_REGEX = re.compile(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})') +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string +}) + # pylint: disable=unused-argument def get_scanner(hass, config): """Return a BT Home Hub 5 scanner if successful.""" - if not validate_config(config, - {DOMAIN: [CONF_HOST]}, - _LOGGER): - return None scanner = BTHomeHub5DeviceScanner(config[DOMAIN]) return scanner if scanner.success_init else None @@ -44,7 +45,7 @@ class BTHomeHub5DeviceScanner(object): def __init__(self, config): """Initialise the scanner.""" - _LOGGER.info("Initialising BT Home Hub 5") + _LOGGER.info('Initialising BT Home Hub 5') self.host = config.get(CONF_HOST, '192.168.1.254') self.lock = threading.Lock() @@ -85,7 +86,7 @@ class BTHomeHub5DeviceScanner(object): return False with self.lock: - _LOGGER.info("Scanning") + _LOGGER.info('Scanning') data = _get_homehub_data(self.url) diff --git a/homeassistant/components/device_tracker/ddwrt.py b/homeassistant/components/device_tracker/ddwrt.py index 02f49fe7475..4dc6229566c 100644 --- a/homeassistant/components/device_tracker/ddwrt.py +++ b/homeassistant/components/device_tracker/ddwrt.py @@ -10,10 +10,11 @@ import threading from datetime import timedelta import requests +import voluptuous as vol -from homeassistant.components.device_tracker import DOMAIN +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import validate_config from homeassistant.util import Throttle # Return cached results if last scan was less then this time ago. @@ -24,15 +25,16 @@ _LOGGER = logging.getLogger(__name__) _DDWRT_DATA_REGEX = re.compile(r'\{(\w+)::([^\}]*)\}') _MAC_REGEX = re.compile(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})') +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string +}) + # pylint: disable=unused-argument def get_scanner(hass, config): """Validate the configuration and return a DD-WRT scanner.""" - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, - _LOGGER): - return None - scanner = DdWrtDeviceScanner(config[DOMAIN]) return scanner if scanner.success_init else None @@ -107,7 +109,7 @@ class DdWrtDeviceScanner(object): return False with self.lock: - _LOGGER.info("Checking ARP") + _LOGGER.info('Checking ARP') url = 'http://{}/Status_Wireless.live.asp'.format(self.host) data = self.get_ddwrt_data(url) @@ -143,18 +145,18 @@ class DdWrtDeviceScanner(object): auth=(self.username, self.password), timeout=4) except requests.exceptions.Timeout: - _LOGGER.exception("Connection to the router timed out") + _LOGGER.exception('Connection to the router timed out') return if response.status_code == 200: return _parse_ddwrt_response(response.text) elif response.status_code == 401: # Authentication error _LOGGER.exception( - "Failed to authenticate, " - "please check your username and password") + 'Failed to authenticate, ' + 'please check your username and password') return else: - _LOGGER.error("Invalid response from ddwrt: %s", response) + _LOGGER.error('Invalid response from ddwrt: %s', response) def _parse_ddwrt_response(data_str): diff --git a/homeassistant/components/device_tracker/fritz.py b/homeassistant/components/device_tracker/fritz.py index 8def71cce73..202919871ad 100644 --- a/homeassistant/components/device_tracker/fritz.py +++ b/homeassistant/components/device_tracker/fritz.py @@ -7,9 +7,11 @@ https://home-assistant.io/components/device_tracker.fritz/ import logging from datetime import timedelta -from homeassistant.components.device_tracker import DOMAIN +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import validate_config from homeassistant.util import Throttle REQUIREMENTS = ['https://github.com/deisi/fritzconnection/archive/' @@ -21,14 +23,17 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) +CONF_DEFAULT_IP = '169.254.1.1' # This IP is valid for all FRITZ!Box routers. + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string, + vol.Optional(CONF_PASSWORD, default='admin'): cv.string, + vol.Optional(CONF_USERNAME, default=''): cv.string +}) + def get_scanner(hass, config): """Validate the configuration and return FritzBoxScanner.""" - if not validate_config(config, - {DOMAIN: []}, - _LOGGER): - return None - scanner = FritzBoxScanner(config[DOMAIN]) return scanner if scanner.success_init else None @@ -40,22 +45,14 @@ class FritzBoxScanner(object): def __init__(self, config): """Initialize the scanner.""" self.last_results = [] - self.host = '169.254.1.1' # This IP is valid for all FRITZ!Box router. - self.username = 'admin' - self.password = '' + self.host = config[CONF_HOST] + self.username = config[CONF_USERNAME] + self.password = config[CONF_PASSWORD] self.success_init = True # pylint: disable=import-error import fritzconnection as fc - # Check for user specific configuration - if CONF_HOST in config.keys(): - self.host = config[CONF_HOST] - if CONF_USERNAME in config.keys(): - self.username = config[CONF_USERNAME] - if CONF_PASSWORD in config.keys(): - self.password = config[CONF_PASSWORD] - # Establish a connection to the FRITZ!Box. try: self.fritz_box = fc.FritzHosts(address=self.host, @@ -70,25 +67,25 @@ class FritzBoxScanner(object): self.success_init = False if self.success_init: - _LOGGER.info("Successfully connected to %s", + _LOGGER.info('Successfully connected to %s', self.fritz_box.modelname) self._update_info() else: - _LOGGER.error("Failed to establish connection to FRITZ!Box " - "with IP: %s", self.host) + _LOGGER.error('Failed to establish connection to FRITZ!Box ' + 'with IP: %s', self.host) def scan_devices(self): """Scan for new devices and return a list of found device ids.""" self._update_info() active_hosts = [] for known_host in self.last_results: - if known_host["status"] == "1": - active_hosts.append(known_host["mac"]) + if known_host['status'] == '1': + active_hosts.append(known_host['mac']) return active_hosts def get_device_name(self, mac): """Return the name of the given device or None if is not known.""" - ret = self.fritz_box.get_specific_host_entry(mac)["NewHostName"] + ret = self.fritz_box.get_specific_host_entry(mac)['NewHostName'] if ret == {}: return None return ret @@ -99,6 +96,6 @@ class FritzBoxScanner(object): if not self.success_init: return False - _LOGGER.info("Scanning") + _LOGGER.info('Scanning') self.last_results = self.fritz_box.get_hosts_info() return True diff --git a/homeassistant/components/device_tracker/luci.py b/homeassistant/components/device_tracker/luci.py index 3b0c7c0bbe5..b97993f9afa 100644 --- a/homeassistant/components/device_tracker/luci.py +++ b/homeassistant/components/device_tracker/luci.py @@ -11,10 +11,11 @@ import threading from datetime import timedelta import requests +import voluptuous as vol -from homeassistant.components.device_tracker import DOMAIN +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import validate_config from homeassistant.util import Throttle # Return cached results if last scan was less then this time ago. @@ -22,14 +23,15 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string +}) + def get_scanner(hass, config): """Validate the configuration and return a Luci scanner.""" - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, - _LOGGER): - return None - scanner = LuciDeviceScanner(config[DOMAIN]) return scanner if scanner.success_init else None @@ -93,7 +95,7 @@ class LuciDeviceScanner(object): return False with self.lock: - _LOGGER.info("Checking ARP") + _LOGGER.info('Checking ARP') url = 'http://{}/cgi-bin/luci/rpc/sys'.format(self.host) result = _req_json_rpc(url, 'net.arptable', @@ -117,19 +119,19 @@ def _req_json_rpc(url, method, *args, **kwargs): try: res = requests.post(url, data=data, timeout=5, **kwargs) except requests.exceptions.Timeout: - _LOGGER.exception("Connection to the router timed out") + _LOGGER.exception('Connection to the router timed out') return if res.status_code == 200: try: result = res.json() except ValueError: # If json decoder could not parse the response - _LOGGER.exception("Failed to parse response from luci") + _LOGGER.exception('Failed to parse response from luci') return try: return result['result'] except KeyError: - _LOGGER.exception("No result in response from luci") + _LOGGER.exception('No result in response from luci') return elif res.status_code == 401: # Authentication error @@ -138,7 +140,7 @@ def _req_json_rpc(url, method, *args, **kwargs): "please check your username and password") return else: - _LOGGER.error("Invalid response from luci: %s", res) + _LOGGER.error('Invalid response from luci: %s', res) def _get_token(host, username, password): diff --git a/homeassistant/components/device_tracker/mqtt.py b/homeassistant/components/device_tracker/mqtt.py index 0998e227857..2318eb44dd1 100644 --- a/homeassistant/components/device_tracker/mqtt.py +++ b/homeassistant/components/device_tracker/mqtt.py @@ -9,13 +9,12 @@ import logging import voluptuous as vol import homeassistant.components.mqtt as mqtt +from homeassistant.const import CONF_DEVICES from homeassistant.components.mqtt import CONF_QOS import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['mqtt'] -CONF_DEVICES = 'devices' - _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py index b20e5aae60e..ff6fe2f1e41 100644 --- a/homeassistant/components/device_tracker/netgear.py +++ b/homeassistant/components/device_tracker/netgear.py @@ -8,9 +8,12 @@ import logging import threading from datetime import timedelta -from homeassistant.components.device_tracker import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, \ - CONF_PORT +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT) from homeassistant.util import Throttle # Return cached results if last scan was less then this time ago. @@ -19,6 +22,17 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['pynetgear==0.3.3'] +DEFAULT_HOST = 'routerlogin.net' +DEFAULT_USER = 'admin' +DEFAULT_PORT = 5000 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USER): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port +}) + def get_scanner(hass, config): """Validate the configuration and returns a Netgear scanner.""" @@ -28,10 +42,6 @@ def get_scanner(hass, config): password = info.get(CONF_PASSWORD) port = info.get(CONF_PORT) - if password is not None and host is None: - _LOGGER.warning('Found username or password but no host') - return None - scanner = NetgearDeviceScanner(host, username, password, port) return scanner if scanner.success_init else None @@ -47,16 +57,9 @@ class NetgearDeviceScanner(object): self.last_results = [] self.lock = threading.Lock() - if host is None: - self._api = pynetgear.Netgear() - elif username is None: - self._api = pynetgear.Netgear(password, host) - elif port is None: - self._api = pynetgear.Netgear(password, host, username) - else: - self._api = pynetgear.Netgear(password, host, username, port) + self._api = pynetgear.Netgear(password, host, username, port) - _LOGGER.info("Logging in") + _LOGGER.info('Logging in') results = self._api.get_attached_devices() @@ -65,7 +68,7 @@ class NetgearDeviceScanner(object): if self.success_init: self.last_results = results else: - _LOGGER.error("Failed to Login") + _LOGGER.error('Failed to Login') def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" @@ -91,7 +94,7 @@ class NetgearDeviceScanner(object): return with self.lock: - _LOGGER.info("Scanning") + _LOGGER.info('Scanning') results = self._api.get_attached_devices() diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py index 7b9f2e9036b..e23d5f31145 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -22,7 +22,8 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) # Interval in minutes to exclude devices from a scan while they are home -CONF_HOME_INTERVAL = "home_interval" +CONF_HOME_INTERVAL = 'home_interval' +CONF_EXCLUDE = 'exclude' REQUIREMENTS = ['python-nmap==0.6.1'] @@ -60,6 +61,7 @@ class NmapDeviceScanner(object): self.last_results = [] self.hosts = config[CONF_HOSTS] + self.exclude = config.get(CONF_EXCLUDE, []) minutes = convert(config.get(CONF_HOME_INTERVAL), int, 0) self.home_interval = timedelta(minutes=minutes) @@ -93,7 +95,8 @@ class NmapDeviceScanner(object): from nmap import PortScanner, PortScannerError scanner = PortScanner() - options = "-F --host-timeout 5s" + options = "-F --host-timeout 5s " + exclude = "--exclude " if self.home_interval: boundary = dt_util.now() - self.home_interval @@ -102,10 +105,16 @@ class NmapDeviceScanner(object): if last_results: # Pylint is confused here. # pylint: disable=no-member - options += " --exclude {}".format(",".join(device.ip for device - in last_results)) + exclude_hosts = self.exclude + [device.ip for device + in last_results] + else: + exclude_hosts = self.exclude else: last_results = [] + exclude_hosts = self.exclude + if exclude_hosts: + exclude = " --exclude {}".format(",".join(exclude_hosts)) + options += exclude try: result = scanner.scan(hosts=self.hosts, arguments=options) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index cdb1f90ba8a..4f6e6647f1c 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -9,9 +9,14 @@ import logging import threading from collections import defaultdict +import voluptuous as vol + +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 +from homeassistant.components import zone as zone_comp +from homeassistant.components.device_tracker import PLATFORM_SCHEMA DEPENDENCIES = ['mqtt'] @@ -22,20 +27,35 @@ BEACON_DEV_ID = 'beacon' LOCATION_TOPIC = 'owntracks/+/+' EVENT_TOPIC = 'owntracks/+/+/event' +WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoint' _LOGGER = logging.getLogger(__name__) LOCK = threading.Lock() CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' +CONF_WAYPOINT_IMPORT = 'waypoints' +CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' VALIDATE_LOCATION = 'location' VALIDATE_TRANSITION = 'transition' +VALIDATE_WAYPOINTS = 'waypoints' + +WAYPOINT_LAT_KEY = 'lat' +WAYPOINT_LON_KEY = 'lon' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), + vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean, + vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All(cv.ensure_list, [cv.string]) +}) def setup_scanner(hass, config, see): """Setup 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) def validate_payload(payload, data_type): """Validate OwnTracks payload.""" @@ -50,7 +70,7 @@ def setup_scanner(hass, config, see): 'because of missing or malformatted data: %s', data_type, data) return None - if data_type == VALIDATE_TRANSITION: + 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: @@ -182,6 +202,26 @@ def setup_scanner(hass, config, see): data['event']) return + def 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(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'] + zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, + zone_comp.ICON_IMPORT, False, True) + zone_comp.add_zone(hass, pretty_name, zone) + def see_beacons(dev_id, kwargs_param): """Set active beacons to the current location.""" kwargs = kwargs_param.copy() @@ -195,18 +235,39 @@ def setup_scanner(hass, config, see): mqtt.subscribe(hass, LOCATION_TOPIC, owntracks_location_update, 1) mqtt.subscribe(hass, EVENT_TOPIC, owntracks_event_update, 1) + if waypoint_import: + if waypoint_whitelist is None: + mqtt.subscribe(hass, WAYPOINT_TOPIC.format('+', '+'), + owntracks_waypoint_update, 1) + else: + for whitelist_user in waypoint_whitelist: + mqtt.subscribe(hass, WAYPOINT_TOPIC.format(whitelist_user, + '+'), + owntracks_waypoint_update, 1) + return True +def parse_topic(topic, pretty=False): + """Parse an MQTT topic owntracks/user/dev, return (user, dev) tuple.""" + 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) + + def _parse_see_args(topic, data): """Parse the OwnTracks location parameters, into the format see expects.""" - parts = topic.split('/') - dev_id = slugify('{}_{}'.format(parts[1], parts[2])) - host_name = parts[1] + (host_name, dev_id) = parse_topic(topic, False) kwargs = { 'dev_id': dev_id, 'host_name': host_name, - 'gps': (data['lat'], data['lon']) + 'gps': (data[WAYPOINT_LAT_KEY], data[WAYPOINT_LON_KEY]) } if 'acc' in data: kwargs['gps_accuracy'] = data['acc'] diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index 9981b4d7ca6..56f9eb4aae6 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -9,9 +9,11 @@ import logging import threading from datetime import timedelta -from homeassistant.components.device_tracker import DOMAIN +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST -from homeassistant.helpers import validate_config from homeassistant.util import Throttle # Return cached results if last scan was less then this time ago. @@ -23,15 +25,16 @@ REQUIREMENTS = ['pysnmp==4.3.2'] CONF_COMMUNITY = "community" CONF_BASEOID = "baseoid" +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_COMMUNITY): cv.string, + vol.Required(CONF_BASEOID): cv.string +}) + # pylint: disable=unused-argument def get_scanner(hass, config): """Validate the configuration and return an snmp scanner.""" - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_COMMUNITY, CONF_BASEOID]}, - _LOGGER): - return None - scanner = SnmpScanner(config[DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/device_tracker/tomato.py b/homeassistant/components/device_tracker/tomato.py index f5282feb733..f463c5a809d 100644 --- a/homeassistant/components/device_tracker/tomato.py +++ b/homeassistant/components/device_tracker/tomato.py @@ -11,10 +11,11 @@ import threading from datetime import timedelta import requests +import voluptuous as vol -from homeassistant.components.device_tracker import DOMAIN +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import validate_config from homeassistant.util import Throttle # Return cached results if last scan was less then this time ago. @@ -24,15 +25,16 @@ CONF_HTTP_ID = "http_id" _LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_HTTP_ID): cv.string +}) + def get_scanner(hass, config): """Validate the configuration and returns a Tomato scanner.""" - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME, - CONF_PASSWORD, CONF_HTTP_ID]}, - _LOGGER): - return None - return TomatoDeviceScanner(config[DOMAIN]) diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py index 17beab02532..ad295099bf5 100755 --- a/homeassistant/components/device_tracker/tplink.py +++ b/homeassistant/components/device_tracker/tplink.py @@ -277,8 +277,10 @@ class Tplink4DeviceScanner(TplinkDeviceScanner): _LOGGER.info("Retrieving auth tokens...") url = 'http://{}/userRpm/LoginRpm.htm?Save=Save'.format(self.host) - # Generate md5 hash of password - password = hashlib.md5(self.password.encode('utf')).hexdigest() + # Generate md5 hash of password. The C7 appears to use the first 15 + # characters of the password only, so we truncate to remove additional + # characters from being hashed. + password = hashlib.md5(self.password.encode('utf')[:15]).hexdigest() credentials = '{}:{}'.format(self.username, password).encode('utf') # Encode the credentials to be sent as a cookie. diff --git a/homeassistant/components/device_tracker/ubus.py b/homeassistant/components/device_tracker/ubus.py index 736c1ba3168..5eaa4bf2fca 100644 --- a/homeassistant/components/device_tracker/ubus.py +++ b/homeassistant/components/device_tracker/ubus.py @@ -11,10 +11,11 @@ import threading from datetime import timedelta import requests +import voluptuous as vol -from homeassistant.components.device_tracker import DOMAIN +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import validate_config from homeassistant.util import Throttle # Return cached results if last scan was less then this time ago. @@ -22,14 +23,15 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string +}) + def get_scanner(hass, config): """Validate the configuration and return an ubus scanner.""" - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, - _LOGGER): - return None - scanner = UbusDeviceScanner(config[DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index 2ae3f76e5e6..d654c3e3eef 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -6,10 +6,11 @@ https://home-assistant.io/components/device_tracker.unifi/ """ import logging import urllib +import voluptuous as vol -from homeassistant.components.device_tracker import DOMAIN +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD -from homeassistant.helpers import validate_config # Unifi package doesn't list urllib3 as a requirement REQUIREMENTS = ['urllib3', 'unifi==1.2.5'] @@ -18,28 +19,24 @@ _LOGGER = logging.getLogger(__name__) CONF_PORT = 'port' CONF_SITE_ID = 'site_id' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default='localhost'): cv.string, + vol.Optional(CONF_SITE_ID, default='default'): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PORT, default=8443): cv.port +}) + def get_scanner(hass, config): """Setup Unifi device_tracker.""" from unifi.controller import Controller - if not validate_config(config, {DOMAIN: [CONF_USERNAME, - CONF_PASSWORD]}, - _LOGGER): - _LOGGER.error('Invalid configuration') - return False - - this_config = config[DOMAIN] - host = this_config.get(CONF_HOST, 'localhost') - username = this_config.get(CONF_USERNAME) - password = this_config.get(CONF_PASSWORD) - site_id = this_config.get(CONF_SITE_ID, 'default') - - try: - port = int(this_config.get(CONF_PORT, 8443)) - except ValueError: - _LOGGER.error('Invalid port (must be numeric like 8443)') - return False + host = config[DOMAIN].get(CONF_HOST) + username = config[DOMAIN].get(CONF_USERNAME) + password = config[DOMAIN].get(CONF_PASSWORD) + site_id = config[DOMAIN].get(CONF_SITE_ID) + port = config[DOMAIN].get(CONF_PORT) try: ctrl = Controller(host, username, password, port, 'v4', site_id) diff --git a/homeassistant/components/downloader.py b/homeassistant/components/downloader.py index b752743d2d4..57b6bd4dc6d 100644 --- a/homeassistant/components/downloader.py +++ b/homeassistant/components/downloader.py @@ -12,10 +12,11 @@ import threading import requests import voluptuous as vol -from homeassistant.helpers import validate_config import homeassistant.helpers.config_validation as cv from homeassistant.util import sanitize_filename +_LOGGER = logging.getLogger(__name__) + ATTR_SUBDIR = 'subdir' ATTR_URL = 'url' @@ -30,15 +31,16 @@ SERVICE_DOWNLOAD_FILE_SCHEMA = vol.Schema({ vol.Optional(ATTR_SUBDIR): cv.string, }) +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOWNLOAD_DIR): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + # pylint: disable=too-many-branches def setup(hass, config): """Listen for download events to download files.""" - logger = logging.getLogger(__name__) - - if not validate_config(config, {DOMAIN: [CONF_DOWNLOAD_DIR]}, logger): - return False - download_path = config[DOMAIN][CONF_DOWNLOAD_DIR] # If path is relative, we assume relative to HASS config dir @@ -46,8 +48,7 @@ def setup(hass, config): download_path = hass.config.path(download_path) if not os.path.isdir(download_path): - - logger.error( + _LOGGER.error( "Download path %s does not exist. File Downloader not active.", download_path) @@ -113,16 +114,16 @@ def setup(hass, config): final_path = "{}_{}.{}".format(path, tries, ext) - logger.info("%s -> %s", url, final_path) + _LOGGER.info("%s -> %s", url, final_path) with open(final_path, 'wb') as fil: for chunk in req.iter_content(1024): fil.write(chunk) - logger.info("Downloading of %s done", url) + _LOGGER.info("Downloading of %s done", url) except requests.exceptions.ConnectionError: - logger.exception("ConnectionError occured for %s", url) + _LOGGER.exception("ConnectionError occured 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/ecobee.py b/homeassistant/components/ecobee.py index 702c7fd6304..24d47365a54 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -7,6 +7,7 @@ https://home-assistant.io/components/ecobee/ import logging import os from datetime import timedelta + import voluptuous as vol import homeassistant.helpers.config_validation as cv @@ -15,14 +16,23 @@ from homeassistant.const import CONF_API_KEY from homeassistant.loader import get_component from homeassistant.util import Throttle -DOMAIN = "ecobee" -NETWORK = None -CONF_HOLD_TEMP = 'hold_temp' - REQUIREMENTS = [ 'https://github.com/nkgilley/python-ecobee-api/archive/' '4856a704670c53afe1882178a89c209b5f98533d.zip#python-ecobee==0.0.6'] +_CONFIGURING = {} +_LOGGER = logging.getLogger(__name__) + +CONF_HOLD_TEMP = 'hold_temp' + +DOMAIN = 'ecobee' + +ECOBEE_CONFIG_FILE = 'ecobee.conf' + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) + +NETWORK = None + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_API_KEY): cv.string, @@ -30,14 +40,6 @@ CONFIG_SCHEMA = vol.Schema({ }) }, extra=vol.ALLOW_EXTRA) -_LOGGER = logging.getLogger(__name__) - -ECOBEE_CONFIG_FILE = 'ecobee.conf' -_CONFIGURING = {} - -# Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) - def request_configuration(network, hass, config): """Request configuration steps from the user.""" @@ -97,7 +99,7 @@ class EcobeeData(object): def update(self): """Get the latest data from pyecobee.""" self.ecobee.update() - _LOGGER.info("ecobee data updated successfully.") + _LOGGER.info("Ecobee data updated successfully") def setup(hass, config): @@ -116,9 +118,6 @@ def setup(hass, config): # Create ecobee.conf if it doesn't exist if not os.path.isfile(hass.config.path(ECOBEE_CONFIG_FILE)): - if config[DOMAIN].get(CONF_API_KEY) is None: - _LOGGER.error("No ecobee api_key found in config.") - return jsonconfig = {"API_KEY": config[DOMAIN].get(CONF_API_KEY)} config_from_file(hass.config.path(ECOBEE_CONFIG_FILE), jsonconfig) diff --git a/homeassistant/components/emulated_hue.py b/homeassistant/components/emulated_hue.py index f7a353d5c7f..d39a1602ec2 100755 --- a/homeassistant/components/emulated_hue.py +++ b/homeassistant/components/emulated_hue.py @@ -44,7 +44,7 @@ DEFAULT_LISTEN_PORT = 8300 DEFAULT_OFF_MAPS_TO_ON_DOMAINS = ['script', 'scene'] DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ - 'switch', 'light', 'group', 'input_boolean', 'media_player' + 'switch', 'light', 'group', 'input_boolean', 'media_player', 'fan' ] HUE_API_STATE_ON = 'on' diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 13244569dbb..a129ece3609 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -39,6 +39,7 @@ SERVICE_OSCILLATE = 'oscillate' SPEED_OFF = 'off' SPEED_LOW = 'low' SPEED_MED = 'med' +SPEED_MEDIUM = 'medium' SPEED_HIGH = 'high' ATTR_SPEED = 'speed' diff --git a/homeassistant/components/fan/demo.py b/homeassistant/components/fan/demo.py index 83508063fa9..ba2deb83125 100644 --- a/homeassistant/components/fan/demo.py +++ b/homeassistant/components/fan/demo.py @@ -1,5 +1,5 @@ """ -Demo garage door platform that has a fake fan. +Demo fan platform that has a fake fan. For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ @@ -19,7 +19,7 @@ DEMO_SUPPORT = SUPPORT_SET_SPEED | SUPPORT_OSCILLATE # pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """Setup demo garage door platform.""" + """Setup demo fan platform.""" add_devices_callback([ DemoFan(hass, FAN_NAME, STATE_OFF), ]) diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py new file mode 100644 index 00000000000..9d824a715c2 --- /dev/null +++ b/homeassistant/components/fan/mqtt.py @@ -0,0 +1,276 @@ +""" +Support for MQTT fans. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/fan.mqtt/ +""" +import logging +from functools import partial + +import voluptuous as vol + +import homeassistant.components.mqtt as mqtt +from homeassistant.const import (CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, + STATE_ON, STATE_OFF) +from homeassistant.components.mqtt import ( + CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.template import render_with_possible_json_value +from homeassistant.components.fan import (SPEED_LOW, SPEED_MED, SPEED_MEDIUM, + SPEED_HIGH, FanEntity, + SUPPORT_SET_SPEED, SUPPORT_OSCILLATE, + SPEED_OFF, ATTR_SPEED) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ["mqtt"] + +CONF_STATE_VALUE_TEMPLATE = "state_value_template" +CONF_SPEED_STATE_TOPIC = "speed_state_topic" +CONF_SPEED_COMMAND_TOPIC = "speed_command_topic" +CONF_SPEED_VALUE_TEMPLATE = "speed_value_template" +CONF_OSCILLATION_STATE_TOPIC = "oscillation_state_topic" +CONF_OSCILLATION_COMMAND_TOPIC = "oscillation_command_topic" +CONF_OSCILLATION_VALUE_TEMPLATE = "oscillation_value_template" +CONF_PAYLOAD_ON = "payload_on" +CONF_PAYLOAD_OFF = "payload_off" +CONF_PAYLOAD_OSCILLATION_ON = "payload_oscillation_on" +CONF_PAYLOAD_OSCILLATION_OFF = "payload_oscillation_off" +CONF_PAYLOAD_LOW_SPEED = "payload_low_speed" +CONF_PAYLOAD_MEDIUM_SPEED = "payload_medium_speed" +CONF_PAYLOAD_HIGH_SPEED = "payload_high_speed" +CONF_SPEED_LIST = "speeds" + +DEFAULT_NAME = "MQTT Fan" +DEFAULT_PAYLOAD_ON = "ON" +DEFAULT_PAYLOAD_OFF = "OFF" +DEFAULT_OPTIMISTIC = False + +OSCILLATE_ON_PAYLOAD = "oscillate_on" +OSCILLATE_OFF_PAYLOAD = "oscillate_off" + +OSCILLATION = "oscillation" + +PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_SPEED_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_SPEED_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_SPEED_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_OSCILLATION_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_OSCILLATION_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_OSCILLATION_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, + vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, + vol.Optional(CONF_PAYLOAD_OSCILLATION_ON, + default=DEFAULT_PAYLOAD_ON): cv.string, + vol.Optional(CONF_PAYLOAD_OSCILLATION_OFF, + default=DEFAULT_PAYLOAD_OFF): cv.string, + vol.Optional(CONF_PAYLOAD_LOW_SPEED, default=SPEED_LOW): cv.string, + vol.Optional(CONF_PAYLOAD_MEDIUM_SPEED, default=SPEED_MED): cv.string, + vol.Optional(CONF_PAYLOAD_HIGH_SPEED, default=SPEED_HIGH): cv.string, + vol.Optional(CONF_SPEED_LIST, + default=[SPEED_OFF, SPEED_LOW, + SPEED_MED, SPEED_HIGH]): cv.ensure_list, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup MQTT fan platform.""" + add_devices_callback([MqttFan( + hass, + config[CONF_NAME], + { + key: config.get(key) for key in ( + CONF_STATE_TOPIC, + CONF_COMMAND_TOPIC, + CONF_SPEED_STATE_TOPIC, + CONF_SPEED_COMMAND_TOPIC, + CONF_OSCILLATION_STATE_TOPIC, + CONF_OSCILLATION_COMMAND_TOPIC, + ) + }, + { + CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), + ATTR_SPEED: config.get(CONF_SPEED_VALUE_TEMPLATE), + OSCILLATION: config.get(CONF_OSCILLATION_VALUE_TEMPLATE) + }, + config[CONF_QOS], + config[CONF_RETAIN], + { + STATE_ON: config[CONF_PAYLOAD_ON], + STATE_OFF: config[CONF_PAYLOAD_OFF], + OSCILLATE_ON_PAYLOAD: config[CONF_PAYLOAD_OSCILLATION_ON], + OSCILLATE_OFF_PAYLOAD: config[CONF_PAYLOAD_OSCILLATION_OFF], + SPEED_LOW: config[CONF_PAYLOAD_LOW_SPEED], + SPEED_MEDIUM: config[CONF_PAYLOAD_MEDIUM_SPEED], + SPEED_HIGH: config[CONF_PAYLOAD_HIGH_SPEED], + }, + config[CONF_SPEED_LIST], + config[CONF_OPTIMISTIC], + )]) + + +# pylint: disable=too-many-instance-attributes +class MqttFan(FanEntity): + """A MQTT fan component.""" + + # pylint: disable=too-many-arguments + def __init__(self, hass, name, topic, templates, qos, retain, payload, + speed_list, optimistic): + """Initialize MQTT fan.""" + self._hass = hass + self._name = name + self._topic = topic + self._qos = qos + self._retain = retain + self._payload = payload + self._speed_list = speed_list + self._optimistic = optimistic or topic[CONF_STATE_TOPIC] is None + self._optimistic_oscillation = (optimistic or + topic[CONF_OSCILLATION_STATE_TOPIC] + is None) + self._optimistic_speed = (optimistic or + topic[CONF_SPEED_STATE_TOPIC] is None) + self._state = False + self._supported_features = 0 + self._supported_features |= (topic[CONF_OSCILLATION_STATE_TOPIC] + is not None and SUPPORT_OSCILLATE) + self._supported_features |= (topic[CONF_SPEED_STATE_TOPIC] + is not None and SUPPORT_SET_SPEED) + + templates = {key: ((lambda value: value) if tpl is None else + partial(render_with_possible_json_value, hass, tpl)) + for key, tpl in templates.items()} + + def state_received(topic, payload, qos): + """A new MQTT message has been received.""" + payload = templates[CONF_STATE](payload) + if payload == self._payload[STATE_ON]: + self._state = True + elif payload == self._payload[STATE_OFF]: + self._state = False + + self.update_ha_state() + + if self._topic[CONF_STATE_TOPIC] is not None: + mqtt.subscribe(self._hass, self._topic[CONF_STATE_TOPIC], + state_received, self._qos) + + def speed_received(topic, payload, qos): + """A new MQTT message for the speed has been received.""" + payload = templates[ATTR_SPEED](payload) + if payload == self._payload[SPEED_LOW]: + self._speed = SPEED_LOW + elif payload == self._payload[SPEED_MEDIUM]: + self._speed = SPEED_MED + elif payload == self._payload[SPEED_HIGH]: + self._speed = SPEED_HIGH + self.update_ha_state() + + if self._topic[CONF_SPEED_STATE_TOPIC] is not None: + mqtt.subscribe(self._hass, self._topic[CONF_SPEED_STATE_TOPIC], + speed_received, self._qos) + self._speed = SPEED_OFF + elif self._topic[CONF_SPEED_COMMAND_TOPIC] is not None: + self._speed = SPEED_OFF + else: + self._speed = SPEED_OFF + + def oscillation_received(topic, payload, qos): + """A new MQTT message has been received.""" + payload = templates[OSCILLATION](payload) + if payload == self._payload[OSCILLATE_ON_PAYLOAD]: + self._oscillation = True + elif payload == self._payload[OSCILLATE_OFF_PAYLOAD]: + self._oscillation = False + self.update_ha_state() + + if self._topic[CONF_OSCILLATION_STATE_TOPIC] is not None: + mqtt.subscribe(self._hass, + self._topic[CONF_OSCILLATION_STATE_TOPIC], + oscillation_received, self._qos) + self._oscillation = False + if self._topic[CONF_OSCILLATION_COMMAND_TOPIC] is not None: + self._oscillation = False + else: + self._oscillation = False + + @property + def should_poll(self): + """No polling needed for a MQTT fan.""" + return False + + @property + def assumed_state(self): + """Return true if we do optimistic updates.""" + return self._optimistic + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def name(self) -> str: + """Get entity name.""" + return self._name + + @property + def speed_list(self) -> list: + """Get the list of available speeds.""" + return self._speed_list + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._supported_features + + @property + def speed(self): + """Return the current speed.""" + return self._speed + + @property + def oscillating(self): + """Return the oscillation state.""" + return self._oscillation + + def turn_on(self, speed: str=SPEED_MED) -> None: + """Turn on the entity.""" + mqtt.publish(self._hass, self._topic[CONF_COMMAND_TOPIC], + self._payload[STATE_ON], self._qos, self._retain) + self.set_speed(speed) + + def turn_off(self) -> None: + """Turn off the entity.""" + mqtt.publish(self._hass, self._topic[CONF_COMMAND_TOPIC], + self._payload[STATE_OFF], self._qos, self._retain) + + def set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + if self._topic[CONF_SPEED_COMMAND_TOPIC] is not None: + mqtt_payload = SPEED_OFF + if speed == SPEED_LOW: + mqtt_payload = self._payload[SPEED_LOW] + elif speed == SPEED_MED: + mqtt_payload = self._payload[SPEED_MEDIUM] + elif speed == SPEED_HIGH: + mqtt_payload = self._payload[SPEED_HIGH] + else: + mqtt_payload = speed + self._speed = speed + mqtt.publish(self._hass, self._topic[CONF_SPEED_COMMAND_TOPIC], + mqtt_payload, self._qos, self._retain) + self.update_ha_state() + + def oscillate(self, oscillating: bool) -> None: + """Set oscillation.""" + if self._topic[CONF_SPEED_COMMAND_TOPIC] is not None: + self._oscillation = oscillating + mqtt.publish(self._hass, + self._topic[CONF_OSCILLATION_COMMAND_TOPIC], + self._oscillation, self._qos, self._retain) + self.update_ha_state() diff --git a/homeassistant/components/feedreader.py b/homeassistant/components/feedreader.py index 4cc0223ce9b..ce3d46b4751 100644 --- a/homeassistant/components/feedreader.py +++ b/homeassistant/components/feedreader.py @@ -1,5 +1,5 @@ """ -Support for RSS/Atom feed. +Support for RSS/Atom feeds. For more details about this component, please refer to the documentation at https://home-assistant.io/components/feedreader/ @@ -9,22 +9,39 @@ from logging import getLogger from os.path import exists from threading import Lock import pickle + import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.helpers.event import track_utc_time_change +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['feedparser==5.2.1'] + _LOGGER = getLogger(__name__) -DOMAIN = "feedreader" -EVENT_FEEDREADER = "feedreader" -# pylint: disable=no-value-for-parameter + +CONF_URLS = 'urls' + +DOMAIN = 'feedreader' + +EVENT_FEEDREADER = 'feedreader' + +MAX_ENTRIES = 20 + CONFIG_SCHEMA = vol.Schema({ DOMAIN: { - 'urls': [vol.Url()], + vol.Required(CONF_URLS): vol.All(cv.ensure_list, [cv.url]), } }, extra=vol.ALLOW_EXTRA) -MAX_ENTRIES = 20 + + +def setup(hass, config): + """Setup the feedreader component.""" + urls = config.get(DOMAIN)[CONF_URLS] + data_file = hass.config.path("{}.pickle".format(DOMAIN)) + storage = StoredData(data_file) + feeds = [FeedManager(url, hass, storage) for url in urls] + return len(feeds) > 0 # pylint: disable=too-few-public-methods @@ -83,9 +100,8 @@ class FeedManager(object): def _update_and_fire_entry(self, entry): """Update last_entry_timestamp and fire entry.""" - # We are lucky, `published_parsed` data available, - # let's make use of it to publish only new available - # entries since the last run + # We are lucky, `published_parsed` data available, let's make use of + # it to publish only new available entries since the last run if 'published_parsed' in entry.keys(): self._has_published_parsed = True self._last_entry_timestamp = max(entry.published_parsed, @@ -163,12 +179,3 @@ class StoredData(object): _LOGGER.error('Error saving pickled data to %s', self._data_file) self._cache_outdated = True - - -def setup(hass, config): - """Setup the feedreader component.""" - urls = config.get(DOMAIN)['urls'] - data_file = hass.config.path("{}.pickle".format(DOMAIN)) - storage = StoredData(data_file) - feeds = [FeedManager(url, hass, storage) for url in urls] - return len(feeds) > 0 diff --git a/homeassistant/components/foursquare.py b/homeassistant/components/foursquare.py index 6fcd2312bab..b08ba89ca77 100644 --- a/homeassistant/components/foursquare.py +++ b/homeassistant/components/foursquare.py @@ -7,42 +7,51 @@ https://home-assistant.io/components/foursquare/ import logging import os import json -import requests +import requests import voluptuous as vol +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv from homeassistant.components.http import HomeAssistantView -DOMAIN = "foursquare" - -SERVICE_CHECKIN = "checkin" - -EVENT_PUSH = "foursquare.push" -EVENT_CHECKIN = "foursquare.checkin" - -CHECKIN_SERVICE_SCHEMA = vol.Schema({ - vol.Required("venueId"): cv.string, - vol.Optional("eventId"): cv.string, - vol.Optional("shout"): cv.string, - vol.Optional("mentions"): cv.string, - vol.Optional("broadcast"): cv.string, - vol.Optional("ll"): cv.string, - vol.Optional("llAcc"): cv.string, - vol.Optional("alt"): cv.string, - vol.Optional("altAcc"): cv.string, -}) - _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ["http"] +CONF_PUSH_SECRET = 'push_secret' + +DEPENDENCIES = ['http'] +DOMAIN = 'foursquare' + +EVENT_CHECKIN = 'foursquare.checkin' +EVENT_PUSH = 'foursquare.push' + +SERVICE_CHECKIN = 'checkin' + +CHECKIN_SERVICE_SCHEMA = vol.Schema({ + vol.Optional('alt'): cv.string, + vol.Optional('altAcc'): cv.string, + vol.Optional('broadcast'): cv.string, + vol.Optional('eventId'): cv.string, + vol.Optional('ll'): cv.string, + vol.Optional('llAcc'): cv.string, + vol.Optional('mentions'): cv.string, + vol.Optional('shout'): cv.string, + vol.Required('venueId'): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Required(CONF_PUSH_SECRET): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) def setup(hass, config): """Setup the Foursquare component.""" descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), "services.yaml")) + os.path.join(os.path.dirname(__file__), 'services.yaml')) config = config[DOMAIN] @@ -51,7 +60,7 @@ def setup(hass, config): url = ("https://api.foursquare.com/v2/checkins/add" "?oauth_token={}" "&v=20160802" - "&m=swarm").format(config["access_token"]) + "&m=swarm").format(config[CONF_ACCESS_TOKEN]) response = requests.post(url, data=call.data, timeout=10) if response.status_code not in (200, 201): @@ -62,12 +71,12 @@ def setup(hass, config): hass.bus.fire(EVENT_CHECKIN, response.text) # Register our service with Home Assistant. - hass.services.register(DOMAIN, "checkin", checkin_user, + hass.services.register(DOMAIN, 'checkin', checkin_user, descriptions[DOMAIN][SERVICE_CHECKIN], schema=CHECKIN_SERVICE_SCHEMA) - hass.wsgi.register_view(FoursquarePushReceiver(hass, - config["push_secret"])) + hass.wsgi.register_view(FoursquarePushReceiver( + hass, config[CONF_PUSH_SECRET])) return True diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index b1bd204e1ce..796d91cade8 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -2,7 +2,7 @@ FINGERPRINTS = { "core.js": "1fd10c1fcdf56a61f60cf861d5a0368c", - "frontend.html": "610cc799225ede933a9894b64bb35717", + "frontend.html": "20defe06c11b2fa2f076dc92b6c3b0dd", "mdi.html": "710b84acc99b32514f52291aba9cd8e8", "panels/ha-panel-dev-event.html": "3cc881ae8026c0fba5aa67d334a3ab2b", "panels/ha-panel-dev-info.html": "34e2df1af32e60fffcafe7e008a92169", diff --git a/homeassistant/components/frontend/www_static/core.js.gz b/homeassistant/components/frontend/www_static/core.js.gz index 5accff5179510910698628a9e96fb9b1b509cb29..e9a51484a98a59b63b0150aba3350692f79655d8 100644 GIT binary patch delta 18 acmZ4Zn{nZ9Mt1pb4h~(7%NyBe)dB!T$OiKO delta 18 acmZ4Zn{nZ9Mt1pb4vyG>(;L}m)dB!V;0Hqh diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 367b8d15a5a..3d70d83b986 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -1,5 +1,5 @@ \ No newline at end of file +var r=t.propertyDataFromStyles(n._styles,this),i=!this.__notStyleScopeCacheable;i&&(r.key.customStyle=this.customStyle,e=n._styleCache.retrieve(this.is,r.key,this._styles));var a=Boolean(e);a?this._styleProperties=e._styleProperties:this._computeStyleProperties(r.properties),this._computeOwnStyleProperties(),a||(e=o.retrieve(this.is,this._ownStyleProperties,this._styles));var l=Boolean(e)&&!a,h=this._applyStyleProperties(e);a||(h=h&&s?h.cloneNode(!0):h,e={style:h,_scopeSelector:this._scopeSelector,_styleProperties:this._styleProperties},i&&(r.key.customStyle={},this.mixin(r.key.customStyle,this.customStyle),n._styleCache.store(this.is,e,r.key,this._styles)),l||o.store(this.is,Object.create(e),this._ownStyleProperties,this._styles))},_computeStyleProperties:function(e){var n=this._findStyleHost();n._styleProperties||n._computeStyleProperties();var r=Object.create(n._styleProperties),s=t.hostAndRootPropertiesForScope(this);this.mixin(r,s.hostProps),e=e||t.propertyDataFromStyles(n._styles,this).properties,this.mixin(r,e),this.mixin(r,s.rootProps),t.mixinCustomStyle(r,this.customStyle),t.reify(r),this._styleProperties=r},_computeOwnStyleProperties:function(){for(var e,t={},n=0;n0&&l.push(t);return[{removed:a,added:l}]}},Polymer.Collection.get=function(e){return Polymer._collections.get(e)||new Polymer.Collection(e)},Polymer.Collection.applySplices=function(e,t){var n=Polymer._collections.get(e);return n?n._applySplices(t):null},Polymer({is:"dom-repeat",extends:"template",_template:null,properties:{items:{type:Array},as:{type:String,value:"item"},indexAs:{type:String,value:"index"},sort:{type:Function,observer:"_sortChanged"},filter:{type:Function,observer:"_filterChanged"},observe:{type:String,observer:"_observeChanged"},delay:Number,renderedItemCount:{type:Number,notify:!0,readOnly:!0},initialCount:{type:Number,observer:"_initializeChunking"},targetFramerate:{type:Number,value:20},_targetFrameTime:{type:Number,computed:"_computeFrameTime(targetFramerate)"}},behaviors:[Polymer.Templatizer],observers:["_itemsChanged(items.*)"],created:function(){this._instances=[],this._pool=[],this._limit=1/0;var e=this;this._boundRenderChunk=function(){e._renderChunk()}},detached:function(){this.__isDetached=!0;for(var e=0;e=0;t--){var n=this._instances[t];n.isPlaceholder&&t=this._limit&&(n=this._downgradeInstance(t,n.__key__)),e[n.__key__]=t,n.isPlaceholder||n.__setProperty(this.indexAs,t,!0)}this._pool.length=0,this._setRenderedItemCount(this._instances.length),this.fire("dom-change"),this._tryRenderChunk()},_applyFullRefresh:function(){var e,t=this.collection;if(this._sortFn)e=t?t.getKeys():[];else{e=[];var n=this.items;if(n)for(var r=0;r=r;a--)this._detachAndRemoveInstance(a)},_numericSort:function(e,t){return e-t},_applySplicesUserSort:function(e){for(var t,n,r=this.collection,s={},i=0;i=0;i--){var h=a[i];void 0!==h&&this._detachAndRemoveInstance(h)}var c=this;if(l.length){this._filterFn&&(l=l.filter(function(e){return c._filterFn(r.getItem(e))})),l.sort(function(e,t){return c._sortFn(r.getItem(e),r.getItem(t))});var u=0;for(i=0;i>1,a=this._instances[o].__key__,l=this._sortFn(n.getItem(a),r);if(l<0)e=o+1;else{if(!(l>0)){i=o;break}s=o-1}}return i<0&&(i=s+1),this._insertPlaceholder(i,t),i},_applySplicesArrayOrder:function(e){for(var t,n=0;n=0?(e=this.as+"."+e.substring(n+1),i._notifyPath(e,t,!0)):i.__setProperty(this.as,t,!0))}},itemForElement:function(e){var t=this.modelForElement(e);return t&&t[this.as]},keyForElement:function(e){var t=this.modelForElement(e);return t&&t.__key__},indexForElement:function(e){var t=this.modelForElement(e);return t&&t[this.indexAs]}}),Polymer({is:"array-selector",_template:null,properties:{items:{type:Array,observer:"clearSelection"},multi:{type:Boolean,value:!1,observer:"clearSelection"},selected:{type:Object,notify:!0},selectedItem:{type:Object,notify:!0},toggle:{type:Boolean,value:!1}},clearSelection:function(){if(Array.isArray(this.selected))for(var e=0;e \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/frontend.html.gz b/homeassistant/components/frontend/www_static/frontend.html.gz index 5f719af40144d372731ad7dcb72d0108f03d2991..3c052db8e0c1fec54a7e48a81272bcfe17c2fb65 100644 GIT binary patch delta 81823 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 F2LQLbk%#~Q delta 79555 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 diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz index d42283913c0504b2eedd0f0cd823b07c0565a86b..aa8a5cc85170da70f52c2bda3d694f55d5f9dc59 100644 GIT binary patch delta 16 XcmbQkHHV8`zMF$XSL5kwm?@8;mp)wsNoosk;=BR2!; delta 16 XcmeAW>kwm?@8;l$4LH4#osk;=B~Ju8 diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz index b7118f9fc63191f9c7ede6bd39e2cf9a3c4fcadb..edfb67103e0f9cc3f2cc7f7528f44eed4ebdef48 100644 GIT binary patch delta 16 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 delta 2207 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 diff --git a/homeassistant/components/frontend/www_static/webcomponents-lite.min.js.gz b/homeassistant/components/frontend/www_static/webcomponents-lite.min.js.gz index 4d1e1e6b5b892f4b00e51057b0653c54216b71ab..0bd0568f231d72b785de5e0e10c0a21228f6b5c7 100644 GIT binary patch delta 16 XcmX?{a5#ZozMF$XSL5 self._hmdevice.ELEMENT: - _LOGGER.critical("Button option is not correct for this object!") - return False - - return True - - def _init_data_struct(self): - """Generate a data dict (self._data) from the Homematic metadata. - - NEEDS overwrite by inherit! - """ + def _init_data(self): + """Generate a data dict (self._data) from the Homematic metadata.""" # Add all attributes to data dict for data_note in self._hmdevice.ATTRIBUTENODE: self._data.update({data_note: STATE_UNKNOWN}) + + # init device specified data + self._init_data_struct() + + def _init_data_struct(self): + """Generate a data dict from the Homematic device metadata.""" + raise NotImplementedError diff --git a/homeassistant/components/http.py b/homeassistant/components/http.py index b9a81858d39..37deb41eef4 100644 --- a/homeassistant/components/http.py +++ b/homeassistant/components/http.py @@ -24,21 +24,21 @@ from homeassistant.core import split_entity_id import homeassistant.util.dt as dt_util import homeassistant.helpers.config_validation as cv -DOMAIN = "http" -REQUIREMENTS = ("cherrypy==7.1.0", "static3==0.7.0", "Werkzeug==0.11.10") +DOMAIN = 'http' +REQUIREMENTS = ('cherrypy==7.1.0', 'static3==0.7.0', 'Werkzeug==0.11.11') -CONF_API_PASSWORD = "api_password" -CONF_SERVER_HOST = "server_host" -CONF_SERVER_PORT = "server_port" -CONF_DEVELOPMENT = "development" +CONF_API_PASSWORD = 'api_password' +CONF_SERVER_HOST = 'server_host' +CONF_SERVER_PORT = 'server_port' +CONF_DEVELOPMENT = 'development' CONF_SSL_CERTIFICATE = 'ssl_certificate' CONF_SSL_KEY = 'ssl_key' CONF_CORS_ORIGINS = 'cors_allowed_origins' DATA_API_PASSWORD = 'api_password' -# TLS configuation follows the best-practice guidelines -# specified here: https://wiki.mozilla.org/Security/Server_Side_TLS +# TLS configuation follows the best-practice guidelines specified here: +# https://wiki.mozilla.org/Security/Server_Side_TLS # Intermediate guidelines are followed. SSL_VERSION = ssl.PROTOCOL_SSLv23 SSL_OPTS = ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 @@ -478,7 +478,7 @@ class HomeAssistantView(object): authenticated = True if self.requires_auth and not authenticated: - _LOGGER.warning('Login attempt or request with an invalid' + _LOGGER.warning('Login attempt or request with an invalid ' 'password from %s', request.remote_addr) raise Unauthorized() diff --git a/homeassistant/components/ifttt.py b/homeassistant/components/ifttt.py index a30ef184d7e..123d1a9d382 100644 --- a/homeassistant/components/ifttt.py +++ b/homeassistant/components/ifttt.py @@ -9,21 +9,22 @@ import logging import requests import voluptuous as vol -from homeassistant.helpers import validate_config import homeassistant.helpers.config_validation as cv +REQUIREMENTS = ['pyfttt==0.3'] + _LOGGER = logging.getLogger(__name__) -DOMAIN = "ifttt" - -SERVICE_TRIGGER = 'trigger' - ATTR_EVENT = 'event' ATTR_VALUE1 = 'value1' ATTR_VALUE2 = 'value2' ATTR_VALUE3 = 'value3' -REQUIREMENTS = ['pyfttt==0.3'] +CONF_KEY = 'key' + +DOMAIN = 'ifttt' + +SERVICE_TRIGGER = 'trigger' SERVICE_TRIGGER_SCHEMA = vol.Schema({ vol.Required(ATTR_EVENT): cv.string, @@ -32,6 +33,12 @@ SERVICE_TRIGGER_SCHEMA = vol.Schema({ vol.Optional(ATTR_VALUE3): cv.string, }) +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_KEY): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + def trigger(hass, event, value1=None, value2=None, value3=None): """Trigger a Maker IFTTT recipe.""" @@ -46,10 +53,7 @@ def trigger(hass, event, value1=None, value2=None, value3=None): def setup(hass, config): """Setup the IFTTT service component.""" - if not validate_config(config, {DOMAIN: ['key']}, _LOGGER): - return False - - key = config[DOMAIN]['key'] + key = config[DOMAIN][CONF_KEY] def trigger_service(call): """Handle IFTTT trigger service calls.""" diff --git a/homeassistant/components/keyboard.py b/homeassistant/components/keyboard.py index 1a33b7dc082..65f94c730bc 100644 --- a/homeassistant/components/keyboard.py +++ b/homeassistant/components/keyboard.py @@ -11,8 +11,9 @@ from homeassistant.const import ( SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_UP) -DOMAIN = "keyboard" -REQUIREMENTS = ['pyuserinput==0.1.9'] +REQUIREMENTS = ['pyuserinput==0.1.11'] + +DOMAIN = 'keyboard' TAP_KEY_SCHEMA = vol.Schema({}) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 23afa58b628..f1bc83dfd17 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -98,6 +98,7 @@ LIGHT_TURN_ON_SCHEMA = vol.Schema({ LIGHT_TURN_OFF_SCHEMA = vol.Schema({ ATTR_ENTITY_ID: cv.entity_ids, ATTR_TRANSITION: VALID_TRANSITION, + ATTR_FLASH: vol.In([FLASH_SHORT, FLASH_LONG]), }) LIGHT_TOGGLE_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index b3aa7e59901..0d48e4c794b 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -13,6 +13,7 @@ import voluptuous as vol from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_EFFECT, EFFECT_RANDOM, SUPPORT_BRIGHTNESS, + SUPPORT_EFFECT, SUPPORT_RGB_COLOR, Light) import homeassistant.helpers.config_validation as cv @@ -33,7 +34,8 @@ PLATFORM_SCHEMA = vol.Schema({ vol.Optional('automatic_add', default=False): cv.boolean, }, extra=vol.ALLOW_EXTRA) -SUPPORT_FLUX_LED = SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR +SUPPORT_FLUX_LED = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | + SUPPORT_RGB_COLOR) def setup_platform(hass, config, add_devices_callback, discovery_info=None): diff --git a/homeassistant/components/light/homematic.py b/homeassistant/components/light/homematic.py index 2e233e0e3ff..3f8eb1a22a5 100644 --- a/homeassistant/components/light/homematic.py +++ b/homeassistant/components/light/homematic.py @@ -22,9 +22,11 @@ def setup_platform(hass, config, add_callback_devices, discovery_info=None): if discovery_info is None: return - return homematic.setup_hmdevice_discovery_helper(HMLight, - discovery_info, - add_callback_devices) + return homematic.setup_hmdevice_discovery_helper( + HMLight, + discovery_info, + add_callback_devices + ) class HMLight(homematic.HMDevice, Light): @@ -70,41 +72,8 @@ class HMLight(homematic.HMDevice, Light): if self.available: self._hmdevice.off(self._channel) - def _check_hm_to_ha_object(self): - """Check if possible to use the Homematic object as this HA type.""" - from pyhomematic.devicetypes.actors import Dimmer, Switch - - # Check compatibility from HMDevice - if not super()._check_hm_to_ha_object(): - return False - - # Check if the Homematic device is correct for this HA device - if isinstance(self._hmdevice, Switch): - return True - if isinstance(self._hmdevice, Dimmer): - return True - - _LOGGER.critical("This %s can't be use as light", self._name) - return False - def _init_data_struct(self): """Generate a data dict (self._data) from the Homematic metadata.""" - from pyhomematic.devicetypes.actors import Dimmer, Switch - - super()._init_data_struct() - - # Use STATE - if isinstance(self._hmdevice, Switch): - self._state = "STATE" - # Use LEVEL - if isinstance(self._hmdevice, Dimmer): - self._state = "LEVEL" - - # Add state to data dict - if self._state: - _LOGGER.debug("%s init datadict with main node '%s'", self._name, - self._state) - self._data.update({self._state: STATE_UNKNOWN}) - else: - _LOGGER.critical("Can't correctly init light %s.", self._name) + self._state = "LEVEL" + self._data.update({self._state: STATE_UNKNOWN}) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index b818f4ee932..3ac7f3ae5f6 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -264,8 +264,10 @@ class HueLight(Light): if flash == FLASH_LONG: command['alert'] = 'lselect' + del command['on'] elif flash == FLASH_SHORT: command['alert'] = 'select' + del command['on'] elif self.bridge_type == 'hue': command['alert'] = 'none' @@ -290,6 +292,17 @@ class HueLight(Light): # 900 seconds. command['transitiontime'] = min(9000, kwargs[ATTR_TRANSITION] * 10) + flash = kwargs.get(ATTR_FLASH) + + if flash == FLASH_LONG: + command['alert'] = 'lselect' + del command['on'] + elif flash == FLASH_SHORT: + command['alert'] = 'select' + del command['on'] + elif self.bridge_type == 'hue': + command['alert'] = 'none' + self.bridge.set_light(self.light_id, command) def update(self): diff --git a/homeassistant/components/light/osramlightify.py b/homeassistant/components/light/osramlightify.py index 41a226031d6..a54cf2bcc32 100644 --- a/homeassistant/components/light/osramlightify.py +++ b/homeassistant/components/light/osramlightify.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/light.osramlightify/ """ import logging import socket +import random from datetime import timedelta from homeassistant import util @@ -14,9 +15,12 @@ from homeassistant.components.light import ( Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, + ATTR_EFFECT, ATTR_RGB_COLOR, ATTR_TRANSITION, + EFFECT_RANDOM, SUPPORT_BRIGHTNESS, + SUPPORT_EFFECT, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, @@ -33,7 +37,8 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) SUPPORT_OSRAMLIGHTIFY = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | - SUPPORT_RGB_COLOR | SUPPORT_TRANSITION) + SUPPORT_EFFECT | SUPPORT_RGB_COLOR | + SUPPORT_TRANSITION) def setup_platform(hass, config, add_devices_callback, discovery_info=None): @@ -150,6 +155,13 @@ class OsramLightifyLight(Light): (TEMP_MAX_HASS - TEMP_MIN_HASS)) + TEMP_MIN) self._light.set_temperature(kelvin, fade) + effect = kwargs.get(ATTR_EFFECT) + if effect == EFFECT_RANDOM: + self._light.set_rgb(random.randrange(0, 255), + random.randrange(0, 255), + random.randrange(0, 255), + fade) + self._light.set_luminance(brightness, fade) self.update_ha_state() diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 392be490dc3..d6a6931652b 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -60,6 +60,12 @@ turn_off: description: Duration in seconds it takes to get to next state example: 60 + flash: + description: If the light should flash + values: + - short + - long + toggle: description: Toggles a light diff --git a/homeassistant/components/light/zigbee.py b/homeassistant/components/light/zigbee.py index 1ab6a0b265a..f4406abf7bd 100644 --- a/homeassistant/components/light/zigbee.py +++ b/homeassistant/components/light/zigbee.py @@ -4,18 +4,27 @@ Functionality to use a ZigBee device as a light. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.zigbee/ """ +import voluptuous as vol + from homeassistant.components.light import Light from homeassistant.components.zigbee import ( - ZigBeeDigitalOut, ZigBeeDigitalOutConfig) + ZigBeeDigitalOut, ZigBeeDigitalOutConfig, PLATFORM_SCHEMA) -DEPENDENCIES = ["zigbee"] +CONF_ON_STATE = 'on_state' + +DEFAULT_ON_STATE = 'high' +DEPENDENCIES = ['zigbee'] + +STATES = ['high', 'low'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_ON_STATE, default=DEFAULT_ON_STATE): vol.In(STATES), +}) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Create and add an entity based on the configuration.""" - add_entities([ - ZigBeeLight(hass, ZigBeeDigitalOutConfig(config)) - ]) + add_devices([ZigBeeLight(hass, ZigBeeDigitalOutConfig(config))]) class ZigBeeLight(ZigBeeDigitalOut, Light): diff --git a/homeassistant/components/lock/mqtt.py b/homeassistant/components/lock/mqtt.py index 81ab179efd4..b8f8ad9c5b3 100644 --- a/homeassistant/components/lock/mqtt.py +++ b/homeassistant/components/lock/mqtt.py @@ -8,25 +8,26 @@ import logging import voluptuous as vol -import homeassistant.components.mqtt as mqtt from homeassistant.components.lock import LockDevice -from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE from homeassistant.components.mqtt import ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) +from homeassistant.const import ( + CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE) from homeassistant.helpers import template +import homeassistant.components.mqtt as mqtt import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ['mqtt'] CONF_PAYLOAD_LOCK = 'payload_lock' CONF_PAYLOAD_UNLOCK = 'payload_unlock' DEFAULT_NAME = 'MQTT Lock' +DEFAULT_OPTIMISTIC = False DEFAULT_PAYLOAD_LOCK = 'LOCK' DEFAULT_PAYLOAD_UNLOCK = 'UNLOCK' -DEFAULT_OPTIMISTIC = False +DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -43,15 +44,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the MQTT lock.""" add_devices([MqttLock( hass, - config[CONF_NAME], + config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), - config[CONF_COMMAND_TOPIC], - config[CONF_QOS], - config[CONF_RETAIN], - config[CONF_PAYLOAD_LOCK], - config[CONF_PAYLOAD_UNLOCK], - config[CONF_OPTIMISTIC], - config.get(CONF_VALUE_TEMPLATE))]) + config.get(CONF_COMMAND_TOPIC), + config.get(CONF_QOS), + config.get(CONF_RETAIN), + config.get(CONF_PAYLOAD_LOCK), + config.get(CONF_PAYLOAD_UNLOCK), + config.get(CONF_OPTIMISTIC), + config.get(CONF_VALUE_TEMPLATE) + )]) # pylint: disable=too-many-arguments, too-many-instance-attributes @@ -88,8 +90,8 @@ class MqttLock(LockDevice): # Force into optimistic mode. self._optimistic = True else: - mqtt.subscribe(hass, self._state_topic, message_received, - self._qos) + mqtt.subscribe( + hass, self._state_topic, message_received, self._qos) @property def should_poll(self): diff --git a/homeassistant/components/lock/verisure.py b/homeassistant/components/lock/verisure.py index fe7a9eeaf5a..d758f4dc91d 100644 --- a/homeassistant/components/lock/verisure.py +++ b/homeassistant/components/lock/verisure.py @@ -7,6 +7,7 @@ https://home-assistant.io/components/verisure/ import logging from homeassistant.components.verisure import HUB as hub +from homeassistant.components.verisure import (CONF_LOCKS, CONF_CODE_DIGITS) from homeassistant.components.lock import LockDevice from homeassistant.const import ( ATTR_CODE, STATE_LOCKED, STATE_UNKNOWN, STATE_UNLOCKED) @@ -17,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Verisure platform.""" locks = [] - if int(hub.config.get('locks', '1')): + if int(hub.config.get(CONF_LOCKS, 1)): hub.update_locks() locks.extend([ VerisureDoorlock(device_id) @@ -34,7 +35,7 @@ class VerisureDoorlock(LockDevice): """Initialize the lock.""" self._id = device_id self._state = STATE_UNKNOWN - self._digits = int(hub.config.get('code_digits', '4')) + self._digits = hub.config.get(CONF_CODE_DIGITS) self._changed_by = None @property diff --git a/homeassistant/components/media_player/braviatv.py b/homeassistant/components/media_player/braviatv.py index 3e9e8fdbd44..b4bab417742 100644 --- a/homeassistant/components/media_player/braviatv.py +++ b/homeassistant/components/media_player/braviatv.py @@ -8,20 +8,28 @@ import logging import os import json import re + +import voluptuous as vol + from homeassistant.loader import get_component from homeassistant.components.media_player import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, - SUPPORT_VOLUME_SET, SUPPORT_SELECT_SOURCE, MediaPlayerDevice) -from homeassistant.const import ( - CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON) + SUPPORT_VOLUME_SET, SUPPORT_SELECT_SOURCE, MediaPlayerDevice, + PLATFORM_SCHEMA) +from homeassistant.const import (CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON) +import homeassistant.helpers.config_validation as cv REQUIREMENTS = [ - 'https://github.com/aparraga/braviarc/archive/0.3.3.zip' - '#braviarc==0.3.3'] + 'https://github.com/aparraga/braviarc/archive/0.3.5.zip' + '#braviarc==0.3.5'] BRAVIA_CONFIG_FILE = 'bravia.conf' + CLIENTID_PREFIX = 'HomeAssistant' + +DEFAULT_NAME = 'Sony Bravia TV' + NICKNAME = 'Home Assistant' # Map ip to request id for configuring @@ -34,6 +42,11 @@ SUPPORT_BRAVIA = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def _get_mac_address(ip_address): """Get the MAC address of the device.""" @@ -82,7 +95,7 @@ def _config_from_file(filename, config=None): # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Sony Bravia TV platform.""" host = config.get(CONF_HOST) @@ -98,22 +111,20 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): pin = host_config['pin'] mac = host_config['mac'] name = config.get(CONF_NAME) - add_devices_callback([BraviaTVDevice(host, mac, name, pin)]) + add_devices([BraviaTVDevice(host, mac, name, pin)]) return - setup_bravia(config, pin, hass, add_devices_callback) + setup_bravia(config, pin, hass, add_devices) # pylint: disable=too-many-branches -def setup_bravia(config, pin, hass, add_devices_callback): +def setup_bravia(config, pin, hass, add_devices): """Setup a Sony Bravia TV based on host parameter.""" host = config.get(CONF_HOST) name = config.get(CONF_NAME) - if name is None: - name = "Sony Bravia TV" if pin is None: - request_configuration(config, hass, add_devices_callback) + request_configuration(config, hass, add_devices) return else: mac = _get_mac_address(host) @@ -132,15 +143,13 @@ def setup_bravia(config, pin, hass, add_devices_callback): {host: {'pin': pin, 'host': host, 'mac': mac}}): _LOGGER.error('failed to save config file') - add_devices_callback([BraviaTVDevice(host, mac, name, pin)]) + add_devices([BraviaTVDevice(host, mac, name, pin)]) -def request_configuration(config, hass, add_devices_callback): +def request_configuration(config, hass, add_devices): """Request configuration steps from the user.""" host = config.get(CONF_HOST) name = config.get(CONF_NAME) - if name is None: - name = "Sony Bravia" configurator = get_component('configurator') @@ -158,9 +167,9 @@ def request_configuration(config, hass, add_devices_callback): braviarc = braviarc.BraviaRC(host) braviarc.connect(pin, CLIENTID_PREFIX, NICKNAME) if braviarc.is_connected(): - setup_bravia(config, pin, hass, add_devices_callback) + setup_bravia(config, pin, hass, add_devices) else: - request_configuration(config, hass, add_devices_callback) + request_configuration(config, hass, add_devices) _CONFIGURING[host] = configurator.request_config( hass, name, bravia_configuration_callback, diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 351fb47a368..2b10448b241 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -7,38 +7,49 @@ https://home-assistant.io/components/media_player.cast/ # pylint: disable=import-error import logging +import voluptuous as vol + from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - SUPPORT_STOP, MediaPlayerDevice) + SUPPORT_STOP, MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HOST, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN) +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['pychromecast==0.7.2'] + +_LOGGER = logging.getLogger(__name__) + CONF_IGNORE_CEC = 'ignore_cec' CAST_SPLASH = 'https://home-assistant.io/images/cast/splash.png' + +DEFAULT_PORT = 8009 + SUPPORT_CAST = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA | SUPPORT_STOP + KNOWN_HOSTS = [] -DEFAULT_PORT = 8009 +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST): cv.string, +}) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the cast platform.""" import pychromecast - logger = logging.getLogger(__name__) # import CEC IGNORE attributes ignore_cec = config.get(CONF_IGNORE_CEC, []) if isinstance(ignore_cec, list): pychromecast.IGNORE_CEC += ignore_cec else: - logger.error('CEC config "%s" must be a list.', CONF_IGNORE_CEC) + _LOGGER.error('CEC config "%s" must be a list.', CONF_IGNORE_CEC) hosts = [] @@ -49,7 +60,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hosts = [discovery_info] elif CONF_HOST in config: - hosts = [(config[CONF_HOST], DEFAULT_PORT)] + hosts = [(config.get(CONF_HOST), DEFAULT_PORT)] else: hosts = [tuple(dev[:2]) for dev in pychromecast.discover_chromecasts() diff --git a/homeassistant/components/media_player/cmus.py b/homeassistant/components/media_player/cmus.py index 4726a1fa6a9..dde2e1d28e6 100644 --- a/homeassistant/components/media_player/cmus.py +++ b/homeassistant/components/media_player/cmus.py @@ -6,34 +6,47 @@ https://home-assistant.io/components/media_player.cmus/ """ import logging +import voluptuous as vol + + from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - SUPPORT_VOLUME_SET, SUPPORT_PLAY_MEDIA, SUPPORT_SEEK, + SUPPORT_VOLUME_SET, SUPPORT_PLAY_MEDIA, SUPPORT_SEEK, PLATFORM_SCHEMA, MediaPlayerDevice) -from homeassistant.const import (STATE_OFF, STATE_PAUSED, STATE_PLAYING, - CONF_HOST, CONF_NAME, CONF_PASSWORD, - CONF_PORT) +from homeassistant.const import ( + STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_HOST, CONF_NAME, CONF_PORT, + CONF_PASSWORD) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pycmus==0.1.0'] _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pycmus==0.1.0'] + +DEFAULT_NAME = 'cmus' +DEFAULT_PORT = 3000 SUPPORT_CMUS = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_TURN_OFF | \ SUPPORT_TURN_ON | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ SUPPORT_PLAY_MEDIA | SUPPORT_SEEK +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Inclusive(CONF_HOST, 'remote'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'remote'): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def setup_platform(hass, config, add_devices, discover_info=None): """Setup the CMUS platform.""" from pycmus import exceptions - host = config.get(CONF_HOST, None) - password = config.get(CONF_PASSWORD, None) - port = config.get(CONF_PORT, None) - name = config.get(CONF_NAME, None) - if host and not password: - _LOGGER.error("A password must be set if using a remote cmus server") - return False + host = config.get(CONF_HOST) + password = config.get(CONF_PASSWORD) + port = config.get(CONF_PORT) + name = config.get(CONF_NAME) + try: cmus_remote = CmusDevice(host, password, port, name) except exceptions.InvalidPassword: @@ -43,7 +56,7 @@ def setup_platform(hass, config, add_devices, discover_info=None): class CmusDevice(MediaPlayerDevice): - """Representation of a running CMUS.""" + """Representation of a running cmus.""" # pylint: disable=no-member, too-many-public-methods, abstract-method def __init__(self, server, password, port, name): @@ -51,13 +64,12 @@ class CmusDevice(MediaPlayerDevice): from pycmus import remote if server: - port = port or 3000 - self.cmus = remote.PyCmus(server=server, password=password, - port=port) - auto_name = "cmus-%s" % server + self.cmus = remote.PyCmus( + server=server, password=password, port=port) + auto_name = 'cmus-{}'.format(server) else: self.cmus = remote.PyCmus() - auto_name = "cmus-local" + auto_name = 'cmus-local' self._name = name or auto_name self.status = {} self.update() diff --git a/homeassistant/components/media_player/denon.py b/homeassistant/components/media_player/denon.py index b4bcc9ae5ba..78df50dde76 100644 --- a/homeassistant/components/media_player/denon.py +++ b/homeassistant/components/media_player/denon.py @@ -7,32 +7,34 @@ https://home-assistant.io/components/media_player.denon/ import logging import telnetlib +import voluptuous as vol + from homeassistant.components.media_player import ( - DOMAIN, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, + PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) -from homeassistant.const import CONF_HOST, STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import ( + CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +DEFAULT_NAME = 'Music station' + SUPPORT_DENON = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Denon platform.""" - if not config.get(CONF_HOST): - _LOGGER.error( - "Missing required configuration items in %s: %s", - DOMAIN, - CONF_HOST) - return False + denon = DenonDevice(config.get(CONF_NAME), config.get(CONF_HOST)) - denon = DenonDevice( - config.get("name", "Music station"), - config.get("host") - ) if denon.update(): add_devices([denon]) return True @@ -48,21 +50,21 @@ class DenonDevice(MediaPlayerDevice): """Initialize the Denon device.""" self._name = name self._host = host - self._pwstate = "PWSTANDBY" + self._pwstate = 'PWSTANDBY' self._volume = 0 self._muted = False - self._mediasource = "" + self._mediasource = '' @classmethod def telnet_request(cls, telnet, command): """Execute `command` and return the response.""" - telnet.write(command.encode("ASCII") + b"\r") - return telnet.read_until(b"\r", timeout=0.2).decode("ASCII").strip() + telnet.write(command.encode('ASCII') + b'\r') + return telnet.read_until(b'\r', timeout=0.2).decode('ASCII').strip() def telnet_command(self, command): """Establish a telnet connection and sends `command`.""" telnet = telnetlib.Telnet(self._host) - telnet.write(command.encode("ASCII") + b"\r") + telnet.write(command.encode('ASCII') + b'\r') telnet.read_very_eager() # skip response telnet.close() @@ -70,17 +72,17 @@ class DenonDevice(MediaPlayerDevice): """Get the latest details from the device.""" try: telnet = telnetlib.Telnet(self._host) - except ConnectionRefusedError: + except OSError: return False - self._pwstate = self.telnet_request(telnet, "PW?") + self._pwstate = self.telnet_request(telnet, 'PW?') # PW? sends also SISTATUS, which is not interesting telnet.read_until(b"\r", timeout=0.2) - volume_str = self.telnet_request(telnet, "MV?")[len("MV"):] + volume_str = self.telnet_request(telnet, 'MV?')[len('MV'):] self._volume = int(volume_str) / 60 - self._muted = (self.telnet_request(telnet, "MU?") == "MUON") - self._mediasource = self.telnet_request(telnet, "SI?")[len("SI"):] + self._muted = (self.telnet_request(telnet, 'MU?') == 'MUON') + self._mediasource = self.telnet_request(telnet, 'SI?')[len('SI'):] telnet.close() return True @@ -93,9 +95,9 @@ class DenonDevice(MediaPlayerDevice): @property def state(self): """Return the state of the device.""" - if self._pwstate == "PWSTANDBY": + if self._pwstate == 'PWSTANDBY': return STATE_OFF - if self._pwstate == "PWON": + if self._pwstate == 'PWON': return STATE_ON return STATE_UNKNOWN @@ -122,41 +124,41 @@ class DenonDevice(MediaPlayerDevice): def turn_off(self): """Turn off media player.""" - self.telnet_command("PWSTANDBY") + self.telnet_command('PWSTANDBY') def volume_up(self): """Volume up media player.""" - self.telnet_command("MVUP") + self.telnet_command('MVUP') def volume_down(self): """Volume down media player.""" - self.telnet_command("MVDOWN") + self.telnet_command('MVDOWN') def set_volume_level(self, volume): """Set volume level, range 0..1.""" # 60dB max - self.telnet_command("MV" + str(round(volume * 60)).zfill(2)) + self.telnet_command('MV' + str(round(volume * 60)).zfill(2)) def mute_volume(self, mute): """Mute (true) or unmute (false) media player.""" - self.telnet_command("MU" + ("ON" if mute else "OFF")) + self.telnet_command('MU' + ('ON' if mute else 'OFF')) def media_play(self): """Play media media player.""" - self.telnet_command("NS9A") + self.telnet_command('NS9A') def media_pause(self): """Pause media player.""" - self.telnet_command("NS9B") + self.telnet_command('NS9B') def media_next_track(self): """Send the next track command.""" - self.telnet_command("NS9D") + self.telnet_command('NS9D') def media_previous_track(self): """Send the previous track command.""" - self.telnet_command("NS9E") + self.telnet_command('NS9E') def turn_on(self): """Turn the media player on.""" - self.telnet_command("PWON") + self.telnet_command('PWON') diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index 7a32f02dc56..0a53ffbbed6 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -1,14 +1,22 @@ -"""Support for the DirecTV recievers.""" +""" +Support for the DirecTV recievers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.directv/ +""" +import voluptuous as vol from homeassistant.components.media_player import ( MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, - SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_STOP, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_STOP, PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, MediaPlayerDevice) from homeassistant.const import ( - CONF_HOST, CONF_NAME, STATE_OFF, STATE_PLAYING) + CONF_HOST, CONF_NAME, STATE_OFF, STATE_PLAYING, CONF_PORT) +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['directpy==0.1'] +DEFAULT_NAME = 'DirecTV Receiver' DEFAULT_PORT = 8080 SUPPORT_DTV = SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ @@ -17,6 +25,12 @@ SUPPORT_DTV = SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ KNOWN_HOSTS = [] +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the DirecTV platform.""" @@ -34,8 +48,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): elif CONF_HOST in config: hosts.append([ - config.get(CONF_NAME, 'DirecTV Receiver'), - config[CONF_HOST], DEFAULT_PORT + config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT) ]) dtvs = [] diff --git a/homeassistant/components/media_player/firetv.py b/homeassistant/components/media_player/firetv.py index 02b456a207c..518982a7038 100644 --- a/homeassistant/components/media_player/firetv.py +++ b/homeassistant/components/media_player/firetv.py @@ -7,46 +7,55 @@ https://home-assistant.io/components/media_player.firetv/ import logging import requests +import voluptuous as vol from homeassistant.components.media_player import ( - SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, PLATFORM_SCHEMA, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import ( STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_STANDBY, - STATE_UNKNOWN) + STATE_UNKNOWN, CONF_HOST, CONF_PORT, CONF_NAME, CONF_DEVICE, CONF_DEVICES) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) SUPPORT_FIRETV = SUPPORT_PAUSE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_VOLUME_SET -DOMAIN = 'firetv' -DEVICE_LIST_URL = 'http://{0}/devices/list' -DEVICE_STATE_URL = 'http://{0}/devices/state/{1}' -DEVICE_ACTION_URL = 'http://{0}/devices/action/{1}/{2}' +DEFAULT_DEVICE = 'default' +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'Amazon Fire TV' +DEFAULT_PORT = 5556 +DEVICE_ACTION_URL = 'http://{0}:{1}/devices/action/{2}/{3}' +DEVICE_LIST_URL = 'http://{0}:{1}/devices/list' +DEVICE_STATE_URL = 'http://{0}:{1}/devices/state/{2}' -_LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the FireTV platform.""" - host = config.get('host', 'localhost:5556') - device_id = config.get('device', 'default') + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + device_id = config.get(CONF_DEVICE) + try: - response = requests.get(DEVICE_LIST_URL.format(host)).json() - if device_id in response['devices'].keys(): - add_devices([ - FireTVDevice( - host, - device_id, - config.get('name', 'Amazon Fire TV') - ) - ]) - _LOGGER.info( - 'Device %s accessible and ready for control', device_id) + response = requests.get(DEVICE_LIST_URL.format(host, port)).json() + if device_id in response[CONF_DEVICES].keys(): + add_devices([FireTVDevice(host, port, device_id, name)]) + _LOGGER.info('Device %s accessible and ready for control', + device_id) else: - _LOGGER.warning( - 'Device %s is not registered with firetv-server', device_id) + _LOGGER.warning('Device %s is not registered with firetv-server', + device_id) except requests.exceptions.RequestException: _LOGGER.error('Could not connect to firetv-server at %s', host) @@ -62,9 +71,10 @@ class FireTV(object): be running via Python 2). """ - def __init__(self, host, device_id): + def __init__(self, host, port, device_id): """Initialize the FireTV server.""" self.host = host + self.port = port self.device_id = device_id @property @@ -73,10 +83,7 @@ class FireTV(object): try: response = requests.get( DEVICE_STATE_URL.format( - self.host, - self.device_id - ) - ).json() + self.host, self.port, self.device_id), timeout=10).json() return response.get('state', STATE_UNKNOWN) except requests.exceptions.RequestException: _LOGGER.error( @@ -86,13 +93,8 @@ class FireTV(object): def action(self, action_id): """Perform an action on the device.""" try: - requests.get( - DEVICE_ACTION_URL.format( - self.host, - self.device_id, - action_id - ) - ) + requests.get(DEVICE_ACTION_URL.format( + self.host, self.port, self.device_id, action_id), timeout=10) except requests.exceptions.RequestException: _LOGGER.error( 'Action request for %s was not accepted for device %s', @@ -103,9 +105,9 @@ class FireTVDevice(MediaPlayerDevice): """Representation of an Amazon Fire TV device on the network.""" # pylint: disable=abstract-method - def __init__(self, host, device, name): + def __init__(self, host, port, device, name): """Initialize the FireTV device.""" - self._firetv = FireTV(host, device) + self._firetv = FireTV(host, port, device) self._name = name self._state = STATE_UNKNOWN diff --git a/homeassistant/components/media_player/gpmdp.py b/homeassistant/components/media_player/gpmdp.py index 4fcdff872e2..430db46bca2 100644 --- a/homeassistant/components/media_player/gpmdp.py +++ b/homeassistant/components/media_player/gpmdp.py @@ -9,24 +9,41 @@ import json import os import socket +import voluptuous as vol + from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, - SUPPORT_PAUSE, SUPPORT_VOLUME_SET, SUPPORT_SEEK, MediaPlayerDevice) + SUPPORT_PAUSE, SUPPORT_VOLUME_SET, SUPPORT_SEEK, MediaPlayerDevice, + PLATFORM_SCHEMA) from homeassistant.const import ( - STATE_PLAYING, STATE_PAUSED, STATE_OFF) + STATE_PLAYING, STATE_PAUSED, STATE_OFF, CONF_HOST, CONF_PORT, CONF_NAME) from homeassistant.loader import get_component +import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['websocket-client==0.37.0'] + +_CONFIGURING = {} +_LOGGER = logging.getLogger(__name__) + +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'GPM Desktop Player' +DEFAULT_PORT = 5672 + +GPMDP_CONFIG_FILE = 'gpmpd.conf' + SUPPORT_GPMDP = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ SUPPORT_SEEK | SUPPORT_VOLUME_SET -GPMDP_CONFIG_FILE = 'gpmpd.conf' -_CONFIGURING = {} PLAYBACK_DICT = {'0': STATE_PAUSED, # Stopped '1': STATE_PAUSED, '2': STATE_PLAYING} +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + def request_configuration(hass, config, url, add_devices_callback): """Request configuration steps from the user.""" @@ -78,7 +95,7 @@ def request_configuration(hass, config, url, add_devices_callback): break _CONFIGURING['gpmdp'] = configurator.request_config( - hass, "GPM Desktop Player", gpmdp_configuration_callback, + hass, DEFAULT_NAME, gpmdp_configuration_callback, description=( 'Enter the pin that is displayed in the ' 'Google Play Music Desktop Player.'), @@ -87,21 +104,22 @@ def request_configuration(hass, config, url, add_devices_callback): ) -def setup_gpmdp(hass, config, code, add_devices_callback): +def setup_gpmdp(hass, config, code, add_devices): """Setup gpmdp.""" - name = config.get("name", "GPM Desktop Player") - address = config.get("address") - url = "ws://" + address + ":5672" + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + url = 'ws://{}:{}'.format(host, port) if not code: - request_configuration(hass, config, url, add_devices_callback) + request_configuration(hass, config, url, add_devices) return if 'gpmdp' in _CONFIGURING: configurator = get_component('configurator') configurator.request_done(_CONFIGURING.pop('gpmdp')) - add_devices_callback([GPMDP(name, url, code)]) + add_devices([GPMDP(name, url, code)]) def _load_config(filename): @@ -110,7 +128,7 @@ def _load_config(filename): return {} try: - with open(filename, "r") as fdesc: + with open(filename, 'r') as fdesc: inp = fdesc.read() # In case empty file @@ -126,10 +144,10 @@ def _load_config(filename): def _save_config(filename, config): """Save configuration.""" try: - with open(filename, "w") as fdesc: + with open(filename, 'w') as fdesc: fdesc.write(json.dumps(config, indent=4, sort_keys=True)) except (IOError, TypeError) as error: - _LOGGER.error("Saving config file failed: %s", error) + _LOGGER.error("Saving configuration file failed: %s", error) return False return True @@ -138,7 +156,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Setup the GPMDP platform.""" codeconfig = _load_config(hass.config.path(GPMDP_CONFIG_FILE)) if len(codeconfig): - code = codeconfig.get("CODE") + code = codeconfig.get('CODE') elif discovery_info is not None: if 'gpmdp' in _CONFIGURING: return @@ -258,7 +276,7 @@ class GPMDP(MediaPlayerDevice): @property def media_seek_position(self): - """Time in seconds of current seek positon.""" + """Time in seconds of current seek position.""" return self._seek_position @property @@ -306,9 +324,9 @@ class GPMDP(MediaPlayerDevice): websocket = self.get_ws() if websocket is None: return - websocket.send(json.dumps({"namespace": "playback", - "method": "setCurrentTime", - "arguments": [position*1000]})) + websocket.send(json.dumps({'namespace': 'playback', + 'method': 'setCurrentTime', + 'arguments': [position*1000]})) self.update_ha_state() def volume_up(self): @@ -332,7 +350,7 @@ class GPMDP(MediaPlayerDevice): websocket = self.get_ws() if websocket is None: return - websocket.send(json.dumps({"namespace": "volume", - "method": "setVolume", - "arguments": [volume*100]})) + websocket.send(json.dumps({'namespace': 'volume', + 'method': 'setVolume', + 'arguments': [volume*100]})) self.update_ha_state() diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index e28d84417d6..224f0d48827 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -7,24 +7,45 @@ https://home-assistant.io/components/media_player.kodi/ import logging import urllib +import voluptuous as vol + from homeassistant.components.media_player import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP, - SUPPORT_TURN_OFF, MediaPlayerDevice) + SUPPORT_TURN_OFF, MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import ( - STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING) + STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_HOST, CONF_NAME, + CONF_PORT, CONF_USERNAME, CONF_PASSWORD) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['jsonrpc-requests==0.3'] _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['jsonrpc-requests==0.3'] + +CONF_TURN_OFF_ACTION = 'turn_off_action' + +DEFAULT_NAME = 'Kodi' +DEFAULT_PORT = 8080 + +TURN_OFF_ACTION = [None, 'quit', 'hibernate', 'suspend', 'reboot', 'shutdown'] SUPPORT_KODI = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK | \ SUPPORT_PLAY_MEDIA | SUPPORT_STOP +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_TURN_OFF_ACTION, default=None): vol.In(TURN_OFF_ACTION), + vol.Optional(CONF_USERNAME): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Kodi platform.""" - url = '{}:{}'.format(config.get('host'), config.get('port', '8080')) + url = '{}:{}'.format(config.get(CONF_HOST), config.get(CONF_PORT)) jsonrpc_url = config.get('url') # deprecated if jsonrpc_url: @@ -32,12 +53,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices([ KodiDevice( - config.get('name', 'Kodi'), + config.get(CONF_NAME), url, - auth=( - config.get('user', ''), - config.get('password', '')), - turn_off_action=config.get('turn_off_action', 'none')), + auth=(config.get(CONF_USERNAME), config.get(CONF_PASSWORD)), + turn_off_action=config.get(CONF_TURN_OFF_ACTION)), ]) @@ -176,19 +195,14 @@ class KodiDevice(MediaPlayerDevice): if self._item is not None: return self._item.get( 'title', - self._item.get( - 'label', - self._item.get( - 'file', - 'unknown'))) + self._item.get('label', self._item.get('file', 'unknown'))) @property def supported_media_commands(self): """Flag of media commands that are supported.""" supported_media_commands = SUPPORT_KODI - if self._turn_off_action in [ - 'quit', 'hibernate', 'suspend', 'reboot', 'shutdown']: + if self._turn_off_action in TURN_OFF_ACTION: supported_media_commands |= SUPPORT_TURN_OFF return supported_media_commands diff --git a/homeassistant/components/media_player/lg_netcast.py b/homeassistant/components/media_player/lg_netcast.py index fc0609a7c34..26b7341f747 100644 --- a/homeassistant/components/media_player/lg_netcast.py +++ b/homeassistant/components/media_player/lg_netcast.py @@ -9,32 +9,32 @@ import logging from requests import RequestException import voluptuous as vol + import homeassistant.helpers.config_validation as cv from homeassistant.components.media_player import ( - SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, PLATFORM_SCHEMA, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, SUPPORT_SELECT_SOURCE, MEDIA_TYPE_CHANNEL, MediaPlayerDevice) from homeassistant.const import ( - CONF_PLATFORM, CONF_HOST, CONF_NAME, CONF_ACCESS_TOKEN, + CONF_HOST, CONF_NAME, CONF_ACCESS_TOKEN, STATE_OFF, STATE_PLAYING, STATE_PAUSED, STATE_UNKNOWN) import homeassistant.util as util -_LOGGER = logging.getLogger(__name__) - REQUIREMENTS = ['https://github.com/wokar/pylgnetcast/archive/' 'v0.2.0.zip#pylgnetcast==0.2.0'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'LG TV Remote' + +MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + SUPPORT_LGTV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) - -DEFAULT_NAME = 'LG TV Remote' - -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): "lg_netcast", +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_ACCESS_TOKEN, default=None): @@ -46,7 +46,9 @@ PLATFORM_SCHEMA = vol.Schema({ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the LG TV platform.""" from pylgnetcast import LgNetCastClient - client = LgNetCastClient(config[CONF_HOST], config[CONF_ACCESS_TOKEN]) + client = LgNetCastClient(config.get(CONF_HOST), + config.get(CONF_ACCESS_TOKEN)) + add_devices([LgTVDevice(client, config[CONF_NAME])]) diff --git a/homeassistant/components/media_player/mpchc.py b/homeassistant/components/media_player/mpchc.py index 8f551f8ae8f..8563b551a09 100644 --- a/homeassistant/components/media_player/mpchc.py +++ b/homeassistant/components/media_player/mpchc.py @@ -6,29 +6,39 @@ https://home-assistant.io/components/media_player.mpchc/ """ import logging import re + import requests +import voluptuous as vol from homeassistant.components.media_player import ( SUPPORT_VOLUME_MUTE, SUPPORT_PAUSE, SUPPORT_STOP, SUPPORT_NEXT_TRACK, - SUPPORT_PREVIOUS_TRACK, SUPPORT_VOLUME_STEP, MediaPlayerDevice) + SUPPORT_PREVIOUS_TRACK, SUPPORT_VOLUME_STEP, MediaPlayerDevice, + PLATFORM_SCHEMA) from homeassistant.const import ( - STATE_OFF, STATE_IDLE, STATE_PAUSED, STATE_PLAYING) + STATE_OFF, STATE_IDLE, STATE_PAUSED, STATE_PLAYING, CONF_NAME, CONF_HOST, + CONF_PORT) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +DEFAULT_NAME = 'MPC-HC' +DEFAULT_PORT = 13579 + SUPPORT_MPCHC = SUPPORT_VOLUME_MUTE | SUPPORT_PAUSE | SUPPORT_STOP | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_VOLUME_STEP +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the MPC-HC platform.""" - name = config.get("name", "MPC-HC") - url = '{}:{}'.format(config.get('host'), config.get('port', '13579')) - - if config.get('host') is None: - _LOGGER.error("Missing NPC-HC host address in config") - return False + name = config.get(CONF_NAME) + url = '{}:{}'.format(config.get(CONF_HOST), config.get(CONF_PORT)) add_devices([MpcHcDevice(name, url)]) @@ -49,7 +59,7 @@ class MpcHcDevice(MediaPlayerDevice): self._player_variables = dict() try: - response = requests.get("{}/variables.html".format(self._url), + response = requests.get('{}/variables.html'.format(self._url), data=None, timeout=3) mpchc_variables = re.findall(r'

(.+?)

', diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index c04184d6bda..56af3cd88f9 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -7,28 +7,46 @@ https://home-assistant.io/components/media_player.mpd/ import logging import socket +import voluptuous as vol + from homeassistant.components.media_player import ( - MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, + MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, PLATFORM_SCHEMA, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_SET, SUPPORT_PLAY_MEDIA, MEDIA_TYPE_PLAYLIST, MediaPlayerDevice) -from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING +from homeassistant.const import ( + STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_PORT, CONF_PASSWORD, + CONF_HOST) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['python-mpd2==0.5.5'] _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['python-mpd2==0.5.5'] + +CONF_LOCATION = 'location' + +DEFAULT_LOCATION = 'MPD' +DEFAULT_PORT = 6600 SUPPORT_MPD = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_TURN_OFF | \ SUPPORT_TURN_ON | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ SUPPORT_PLAY_MEDIA +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_LOCATION, default=DEFAULT_LOCATION): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the MPD platform.""" - daemon = config.get('server', None) - port = config.get('port', 6600) - location = config.get('location', 'MPD') - password = config.get('password', None) + daemon = config.get(CONF_HOST) + port = config.get(CONF_PORT) + location = config.get(CONF_LOCATION) + password = config.get(CONF_PASSWORD) import mpd @@ -43,18 +61,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): mpd_client.close() mpd_client.disconnect() except socket.error: - _LOGGER.error( - "Unable to connect to MPD. " - "Please check your settings") - + _LOGGER.error("Unable to connect to MPD") return False except mpd.CommandError as error: if "incorrect password" in str(error): - _LOGGER.error( - "MPD reported incorrect password. " - "Please check your password.") - + _LOGGER.error("MPD reported incorrect password") return False else: raise @@ -65,7 +77,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class MpdDevice(MediaPlayerDevice): """Representation of a MPD server.""" - # MPD confuses pylint # pylint: disable=no-member, too-many-public-methods, abstract-method def __init__(self, server, port, location, password): """Initialize the MPD device.""" diff --git a/homeassistant/components/media_player/onkyo.py b/homeassistant/components/media_player/onkyo.py index d1b5282fa6e..53cd0b68df9 100644 --- a/homeassistant/components/media_player/onkyo.py +++ b/homeassistant/components/media_player/onkyo.py @@ -6,55 +6,69 @@ https://home-assistant.io/components/media_player.onkyo/ """ import logging +import voluptuous as vol + from homeassistant.components.media_player import ( SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - SUPPORT_SELECT_SOURCE, MediaPlayerDevice) -from homeassistant.const import STATE_OFF, STATE_ON, CONF_HOST, CONF_NAME + SUPPORT_SELECT_SOURCE, MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.const import (STATE_OFF, STATE_ON, CONF_HOST, CONF_NAME) +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['https://github.com/danieljkemp/onkyo-eiscp/archive/' 'python3.zip#onkyo-eiscp==0.9.2'] + _LOGGER = logging.getLogger(__name__) +CONF_SOURCES = 'sources' + +DEFAULT_NAME = 'Onkyo Receiver' + +KNOWN_HOSTS = [] + SUPPORT_ONKYO = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE -KNOWN_HOSTS = [] -DEFAULT_SOURCES = {"tv": "TV", "bd": "Bluray", "game": "Game", "aux1": "Aux1", - "video1": "Video 1", "video2": "Video 2", - "video3": "Video 3", "video4": "Video 4", - "video5": "Video 5", "video6": "Video 6", - "video7": "Video 7"} -CONFIG_SOURCE_LIST = "sources" + +DEFAULT_SOURCES = {'tv': 'TV', 'bd': 'Bluray', 'game': 'Game', 'aux1': 'Aux1', + 'video1': 'Video 1', 'video2': 'Video 2', + 'video3': 'Video 3', 'video4': 'Video 4', + 'video5': 'Video 5', 'video6': 'Video 6', + 'video7': 'Video 7'} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SOURCES, default=DEFAULT_SOURCES): + {cv.string: cv.string}, +}) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Onkyo platform.""" import eiscp from eiscp import eISCP + + host = config.get(CONF_HOST) hosts = [] - if CONF_HOST in config and config[CONF_HOST] not in KNOWN_HOSTS: + if CONF_HOST in config and host not in KNOWN_HOSTS: try: - hosts.append(OnkyoDevice(eiscp.eISCP(config[CONF_HOST]), - config.get(CONFIG_SOURCE_LIST, - DEFAULT_SOURCES), - name=config[CONF_NAME])) - KNOWN_HOSTS.append(config[CONF_HOST]) + hosts.append(OnkyoDevice(eiscp.eISCP(host), + config.get(CONF_SOURCES), + name=config.get(CONF_NAME))) + KNOWN_HOSTS.append(host) except OSError: - _LOGGER.error('Unable to connect to receiver at %s.', - config[CONF_HOST]) + _LOGGER.error('Unable to connect to receiver at %s.', host) else: for receiver in eISCP.discover(): if receiver.host not in KNOWN_HOSTS: - hosts.append(OnkyoDevice(receiver, - config.get(CONFIG_SOURCE_LIST, - DEFAULT_SOURCES))) + hosts.append(OnkyoDevice(receiver, config.get(CONF_SOURCES))) KNOWN_HOSTS.append(receiver.host) add_devices(hosts) # pylint: disable=too-many-instance-attributes class OnkyoDevice(MediaPlayerDevice): - """Representation of a Onkyo device.""" + """Representation of an Onkyo device.""" # pylint: disable=too-many-public-methods, abstract-method def __init__(self, receiver, sources, name=None): @@ -90,7 +104,7 @@ class OnkyoDevice(MediaPlayerDevice): self._current_source = '_'.join( [i for i in current_source_raw[1]]) self._muted = bool(mute_raw[1] == 'on') - self._volume = int(volume_raw[1], 16)/80.0 + self._volume = int(volume_raw[1], 16) / 80.0 @property def name(self): diff --git a/homeassistant/components/media_player/panasonic_viera.py b/homeassistant/components/media_player/panasonic_viera.py index ddc547ff807..488e4e6b9d8 100644 --- a/homeassistant/components/media_player/panasonic_viera.py +++ b/homeassistant/components/media_player/panasonic_viera.py @@ -7,33 +7,42 @@ https://home-assistant.io/components/media_player.panasonic_viera/ import logging import socket -from homeassistant.components.media_player import ( - DOMAIN, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, - SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - SUPPORT_VOLUME_STEP, MediaPlayerDevice) -from homeassistant.const import ( - CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN) -from homeassistant.helpers import validate_config +import voluptuous as vol -CONF_PORT = "port" +from homeassistant.components.media_player import ( + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, + SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN, CONF_PORT) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['panasonic_viera==0.2'] _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['panasonic_viera==0.2'] +DEFAULT_NAME = 'Panasonic Viera TV' +DEFAULT_PORT = 55000 SUPPORT_VIERATV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ SUPPORT_TURN_OFF +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Panasonic Viera TV platform.""" - from panasonic_viera import DEFAULT_PORT, RemoteControl + from panasonic_viera import RemoteControl - name = config.get(CONF_NAME, 'Panasonic Viera TV') - port = config.get(CONF_PORT, DEFAULT_PORT) + name = config.get(CONF_NAME) + port = config.get(CONF_PORT) if discovery_info: _LOGGER.debug('%s', discovery_info) @@ -46,13 +55,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices([PanasonicVieraTVDevice(name, remote)]) return True - # Validate that all required config options are given - if not validate_config({DOMAIN: config}, {DOMAIN: [CONF_HOST]}, _LOGGER): - return False - - host = config.get(CONF_HOST, None) - + host = config.get(CONF_HOST) remote = RemoteControl(host, port) + try: remote.get_mute() except (socket.timeout, TimeoutError, OSError): diff --git a/homeassistant/components/media_player/pioneer.py b/homeassistant/components/media_player/pioneer.py index 207e38ecf40..599edf08b37 100644 --- a/homeassistant/components/media_player/pioneer.py +++ b/homeassistant/components/media_player/pioneer.py @@ -7,36 +7,35 @@ https://home-assistant.io/components/media_player.pioneer/ import logging import telnetlib +import voluptuous as vol + from homeassistant.components.media_player import ( - DOMAIN, SUPPORT_PAUSE, SUPPORT_SELECT_SOURCE, - SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - MediaPlayerDevice) + SUPPORT_PAUSE, SUPPORT_SELECT_SOURCE, MediaPlayerDevice, PLATFORM_SCHEMA, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET) from homeassistant.const import ( - CONF_HOST, STATE_OFF, STATE_ON, STATE_UNKNOWN, - CONF_NAME) + CONF_HOST, STATE_OFF, STATE_ON, STATE_UNKNOWN, CONF_NAME) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +DEFAULT_NAME = 'Pioneer AVR' + SUPPORT_PIONEER = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE MAX_VOLUME = 185 MAX_SOURCE_NUMBERS = 60 +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Pioneer platform.""" - if not config.get(CONF_HOST): - _LOGGER.error( - "Missing required configuration items in %s: %s", - DOMAIN, - CONF_HOST) - return False + pioneer = PioneerDevice(config.get(CONF_NAME), config.get(CONF_HOST)) - pioneer = PioneerDevice( - config.get(CONF_NAME, "Pioneer AVR"), - config.get(CONF_HOST) - ) if pioneer.update(): add_devices([pioneer]) return True @@ -53,7 +52,7 @@ class PioneerDevice(MediaPlayerDevice): """Initialize the Pioneer device.""" self._name = name self._host = host - self._pwstate = "PWR1" + self._pwstate = 'PWR1' self._volume = 0 self._muted = False self._selected_source = '' diff --git a/homeassistant/components/media_player/roku.py b/homeassistant/components/media_player/roku.py index 7951530e3e8..e7a87d2d773 100644 --- a/homeassistant/components/media_player/roku.py +++ b/homeassistant/components/media_player/roku.py @@ -6,13 +6,15 @@ https://home-assistant.io/components/media_player.roku/ """ import logging +import voluptuous as vol + from homeassistant.components.media_player import ( MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - SUPPORT_SELECT_SOURCE, MediaPlayerDevice) - + SUPPORT_SELECT_SOURCE, MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HOST, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, STATE_HOME) +import homeassistant.helpers.config_validation as cv REQUIREMENTS = [ 'https://github.com/bah2830/python-roku/archive/3.1.2.zip' @@ -27,6 +29,10 @@ SUPPORT_ROKU = SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK |\ SUPPORT_PLAY_MEDIA | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ SUPPORT_SELECT_SOURCE +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST): cv.string, +}) + # pylint: disable=abstract-method def setup_platform(hass, config, add_devices, discovery_info=None): @@ -41,7 +47,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hosts.append(discovery_info[0]) elif CONF_HOST in config: - hosts.append(config[CONF_HOST]) + hosts.append(config.get(CONF_HOST)) rokus = [] for host in hosts: diff --git a/homeassistant/components/media_player/samsungtv.py b/homeassistant/components/media_player/samsungtv.py index 61768b91f96..5c096c86bb0 100644 --- a/homeassistant/components/media_player/samsungtv.py +++ b/homeassistant/components/media_player/samsungtv.py @@ -7,44 +7,51 @@ https://home-assistant.io/components/media_player.samsungtv/ import logging import socket -from homeassistant.components.media_player import ( - DOMAIN, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, - SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, - MediaPlayerDevice) -from homeassistant.const import ( - CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN) -from homeassistant.helpers import validate_config +import voluptuous as vol -CONF_PORT = "port" -CONF_TIMEOUT = "timeout" +from homeassistant.components.media_player import ( + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, + SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, + MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN, CONF_PORT) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['samsungctl==0.5.1'] _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['samsungctl==0.5.1'] +CONF_TIMEOUT = 'timeout' + +DEFAULT_NAME = 'Samsung TV Remote' +DEFAULT_PORT = 55000 +DEFAULT_TIMEOUT = 0 SUPPORT_SAMSUNGTV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_TURN_OFF +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Samsung TV platform.""" - # Validate that all required config options are given - if not validate_config({DOMAIN: config}, {DOMAIN: [CONF_HOST]}, _LOGGER): - return False + name = config.get(CONF_NAME) - # Default the entity_name to 'Samsung TV Remote' - name = config.get(CONF_NAME, 'Samsung TV Remote') - - # Generate a config for the Samsung lib + # Generate a configuration for the Samsung library remote_config = { - "name": "HomeAssistant", - "description": config.get(CONF_NAME, ''), - "id": "ha.component.samsung", - "port": config.get(CONF_PORT, 55000), - "host": config.get(CONF_HOST), - "timeout": config.get(CONF_TIMEOUT, 0), + 'name': 'HomeAssistant', + 'description': config.get(CONF_NAME), + 'id': 'ha.component.samsung', + 'port': config.get(CONF_PORT), + 'host': config.get(CONF_HOST), + 'timeout': config.get(CONF_TIMEOUT), } add_devices([SamsungTVDevice(name, remote_config)]) @@ -56,7 +63,7 @@ class SamsungTVDevice(MediaPlayerDevice): # pylint: disable=too-many-public-methods def __init__(self, name, config): - """Initialize the samsung device.""" + """Initialize the Samsung device.""" from samsungctl import Remote # Save a reference to the imported class self._remote_class = Remote @@ -124,19 +131,19 @@ class SamsungTVDevice(MediaPlayerDevice): def turn_off(self): """Turn off media player.""" - self.send_key("KEY_POWEROFF") + self.send_key('KEY_POWEROFF') def volume_up(self): """Volume up the media player.""" - self.send_key("KEY_VOLUP") + self.send_key('KEY_VOLUP') def volume_down(self): """Volume down media player.""" - self.send_key("KEY_VOLDOWN") + self.send_key('KEY_VOLDOWN') def mute_volume(self, mute): """Send mute command.""" - self.send_key("KEY_MUTE") + self.send_key('KEY_MUTE') def media_play_pause(self): """Simulate play pause media player.""" @@ -148,21 +155,21 @@ class SamsungTVDevice(MediaPlayerDevice): def media_play(self): """Send play command.""" self._playing = True - self.send_key("KEY_PLAY") + self.send_key('KEY_PLAY') def media_pause(self): """Send media pause command to media player.""" self._playing = False - self.send_key("KEY_PAUSE") + self.send_key('KEY_PAUSE') def media_next_track(self): """Send next track command.""" - self.send_key("KEY_FF") + self.send_key('KEY_FF') def media_previous_track(self): """Send the previous track command.""" - self.send_key("KEY_REWIND") + self.send_key('KEY_REWIND') def turn_on(self): """Turn the media player on.""" - self.send_key("KEY_POWERON") + self.send_key('KEY_POWERON') diff --git a/homeassistant/components/media_player/snapcast.py b/homeassistant/components/media_player/snapcast.py index 998490fb9b9..2be3c36816c 100644 --- a/homeassistant/components/media_player/snapcast.py +++ b/homeassistant/components/media_player/snapcast.py @@ -7,35 +7,45 @@ https://home-assistant.io/components/media_player.snapcast/ import logging import socket +import voluptuous as vol + from homeassistant.components.media_player import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_SELECT_SOURCE, - MediaPlayerDevice) + PLATFORM_SCHEMA, MediaPlayerDevice) from homeassistant.const import ( - STATE_OFF, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN) + STATE_OFF, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, CONF_HOST, CONF_PORT) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['snapcast==1.2.2'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'snapcast' SUPPORT_SNAPCAST = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_SELECT_SOURCE -DOMAIN = 'snapcast' -REQUIREMENTS = ['snapcast==1.2.1'] -_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT): cv.port, +}) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Snapcast platform.""" import snapcast.control - host = config.get('host') - port = config.get('port', snapcast.control.CONTROL_PORT) - if not host: - _LOGGER.error('No snapserver host specified') - return + host = config.get(CONF_HOST) + port = config.get(CONF_PORT, snapcast.control.CONTROL_PORT) + try: server = snapcast.control.Snapserver(host, port) except socket.gaierror: _LOGGER.error('Could not connect to Snapcast server at %s:%d', host, port) - return + return False + add_devices([SnapcastDevice(client) for client in server.clients]) @@ -75,18 +85,18 @@ class SnapcastDevice(MediaPlayerDevice): return { 'idle': STATE_IDLE, 'playing': STATE_PLAYING, - 'unkown': STATE_UNKNOWN, + 'unknown': STATE_UNKNOWN, }.get(self._client.stream.status, STATE_UNKNOWN) @property def source(self): """Return the current input source.""" - return self._client.stream.identifier + return self._client.stream.name @property def source_list(self): """List of available input sources.""" - return self._client.available_streams() + return list(self._client.streams_by_name().keys()) def mute_volume(self, mute): """Send the mute command.""" @@ -98,4 +108,6 @@ class SnapcastDevice(MediaPlayerDevice): def select_source(self, source): """Set input source.""" - self._client.stream = source + streams = self._client.streams_by_name() + if source in streams: + self._client.stream = streams[source].identifier diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 62b2aeabf51..5fc0166aefa 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -199,6 +199,7 @@ class SonosDevice(MediaPlayerDevice): self.hass = hass self.volume_increment = 5 self._player = player + self._name = None self.update() self.soco_snapshot = Snapshot(self._player) @@ -216,11 +217,6 @@ class SonosDevice(MediaPlayerDevice): """Return the name of the device.""" return self._name - @property - def unique_id(self): - """Return a unique ID.""" - return "{}.{}".format(self.__class__, self._player.uid) - @property def state(self): """Return the state of the device.""" diff --git a/homeassistant/components/media_player/squeezebox.py b/homeassistant/components/media_player/squeezebox.py index d49eba609e1..2f8c214fe3a 100644 --- a/homeassistant/components/media_player/squeezebox.py +++ b/homeassistant/components/media_player/squeezebox.py @@ -8,48 +8,53 @@ import logging import telnetlib import urllib.parse +import voluptuous as vol + from homeassistant.components.media_player import ( - DOMAIN, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, + MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, PLATFORM_SCHEMA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, STATE_IDLE, STATE_OFF, - STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN) + STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, CONF_PORT) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +DEFAULT_PORT = 9090 + +KNOWN_DEVICES = [] + SUPPORT_SQUEEZEBOX = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | \ SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ SUPPORT_SEEK | SUPPORT_TURN_ON | SUPPORT_TURN_OFF -KNOWN_DEVICES = [] +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_USERNAME): cv.string, +}) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the squeezebox platform.""" + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + if discovery_info is not None: host = discovery_info[0] - port = 9090 + port = DEFAULT_PORT else: host = config.get(CONF_HOST) - port = int(config.get('port', 9090)) - - if not host: - _LOGGER.error( - "Missing required configuration items in %s: %s", - DOMAIN, - CONF_HOST) - return False + port = config.get(CONF_PORT) # Only add a media server once if host in KNOWN_DEVICES: return False KNOWN_DEVICES.append(host) - lms = LogitechMediaServer( - host, port, - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD)) + lms = LogitechMediaServer(host, port, username, password) if not lms.init_success: return False @@ -77,18 +82,13 @@ class LogitechMediaServer(object): try: http_port = self.query('pref', 'httpport', '?') if not http_port: - _LOGGER.error( - "Unable to read data from server %s:%s", - self.host, - self.port) + _LOGGER.error("Unable to read data from server %s:%s", + self.host, self.port) return return http_port except ConnectionError as ex: - _LOGGER.error( - "Failed to connect to server %s:%s - %s", - self.host, - self.port, - ex) + _LOGGER.error("Failed to connect to server %s:%s - %s", + self.host, self.port, ex) return def create_players(self): diff --git a/homeassistant/components/modbus.py b/homeassistant/components/modbus.py index 1d6ad0e3abc..b0391f9ba45 100644 --- a/homeassistant/components/modbus.py +++ b/homeassistant/components/modbus.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/modbus/ """ import logging +import threading from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) @@ -37,7 +38,7 @@ ATTR_ADDRESS = "address" ATTR_UNIT = "unit" ATTR_VALUE = "value" -NETWORK = None +HUB = None TYPE = None @@ -50,46 +51,116 @@ def setup(hass, config): # Connect to Modbus network # pylint: disable=global-statement, import-error - global NETWORK if TYPE == "serial": from pymodbus.client.sync import ModbusSerialClient as ModbusClient - NETWORK = ModbusClient(method=config[DOMAIN][METHOD], - port=config[DOMAIN][SERIAL_PORT], - baudrate=config[DOMAIN][BAUDRATE], - stopbits=config[DOMAIN][STOPBITS], - bytesize=config[DOMAIN][BYTESIZE], - parity=config[DOMAIN][PARITY]) + client = ModbusClient(method=config[DOMAIN][METHOD], + port=config[DOMAIN][SERIAL_PORT], + baudrate=config[DOMAIN][BAUDRATE], + stopbits=config[DOMAIN][STOPBITS], + bytesize=config[DOMAIN][BYTESIZE], + parity=config[DOMAIN][PARITY]) elif TYPE == "tcp": from pymodbus.client.sync import ModbusTcpClient as ModbusClient - NETWORK = ModbusClient(host=config[DOMAIN][HOST], - port=config[DOMAIN][IP_PORT]) + client = ModbusClient(host=config[DOMAIN][HOST], + port=config[DOMAIN][IP_PORT]) elif TYPE == "udp": from pymodbus.client.sync import ModbusUdpClient as ModbusClient - NETWORK = ModbusClient(host=config[DOMAIN][HOST], - port=config[DOMAIN][IP_PORT]) + client = ModbusClient(host=config[DOMAIN][HOST], + port=config[DOMAIN][IP_PORT]) else: return False + global HUB + HUB = ModbusHub(client) + def stop_modbus(event): """Stop Modbus service.""" - NETWORK.close() + HUB.close() def start_modbus(event): """Start Modbus service.""" - NETWORK.connect() + HUB.connect() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_modbus) # Register services for modbus hass.services.register(DOMAIN, SERVICE_WRITE_REGISTER, write_register) def write_register(service): - """Write modbus register.""" + """Write modbus registers.""" unit = int(float(service.data.get(ATTR_UNIT))) address = int(float(service.data.get(ATTR_ADDRESS))) - value = int(float(service.data.get(ATTR_VALUE))) - NETWORK.write_register(address, value, unit=unit) + value = service.data.get(ATTR_VALUE) + if isinstance(value, list): + HUB.write_registers( + unit, + address, + [int(float(i)) for i in value]) + else: + HUB.write_register( + unit, + address, + int(float(value))) hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_modbus) return True + + +class ModbusHub(object): + """Thread safe wrapper class for pymodbus.""" + + def __init__(self, modbus_client): + """Initialize the modbus hub.""" + self._client = modbus_client + self._lock = threading.Lock() + + def close(self): + """Disconnect client.""" + with self._lock: + self._client.close() + + def connect(self): + """Connect client.""" + with self._lock: + self._client.connect() + + def read_coils(self, unit, address, count): + """Read coils.""" + with self._lock: + return self._client.read_coils( + address, + count, + unit=unit) + + def read_holding_registers(self, unit, address, count): + """Read holding registers.""" + with self._lock: + return self._client.read_holding_registers( + address, + count, + unit=unit) + + def write_coil(self, unit, address, value): + """Write coil.""" + with self._lock: + self._client.write_coil( + address, + value, + unit=unit) + + def write_register(self, unit, address, value): + """Write register.""" + with self._lock: + self._client.write_register( + address, + value, + unit=unit) + + def write_registers(self, unit, address, values): + """Write registers.""" + with self._lock: + self._client.write_registers( + address, + values, + unit=unit) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index e06f60b6e1a..6cf8ed047ee 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -170,9 +170,14 @@ def subscribe(hass, topic, callback, qos=DEFAULT_QOS): callback(event.data[ATTR_TOPIC], event.data[ATTR_PAYLOAD], event.data[ATTR_QOS]) - hass.bus.listen(EVENT_MQTT_MESSAGE_RECEIVED, mqtt_topic_subscriber) + remove = hass.bus.listen(EVENT_MQTT_MESSAGE_RECEIVED, + mqtt_topic_subscriber) + + # Future: track subscriber count and unsubscribe in remove MQTT_CLIENT.subscribe(topic, qos) + return remove + def _setup_server(hass, config): """Try to start embedded MQTT broker.""" diff --git a/homeassistant/components/nest.py b/homeassistant/components/nest.py index 430b9baa956..d875ab0e2c0 100644 --- a/homeassistant/components/nest.py +++ b/homeassistant/components/nest.py @@ -1,18 +1,21 @@ """ -Support for Nest thermostats and protect smoke alarms. +Support for Nest devices. -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/climate.nest/ +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/nest/ """ import logging import socket import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_STRUCTURE +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_PASSWORD, CONF_USERNAME, CONF_STRUCTURE) + +_LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['python-nest==2.9.2'] + DOMAIN = 'nest' NEST = None @@ -21,14 +24,12 @@ STRUCTURES_TO_INCLUDE = None CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_STRUCTURE): vol.All(cv.ensure_list, cv.string) }) }, extra=vol.ALLOW_EXTRA) -_LOGGER = logging.getLogger(__name__) - def devices(): """Generator returning list of devices and their location.""" diff --git a/homeassistant/components/netatmo.py b/homeassistant/components/netatmo.py index a808985ae0e..f56c9b515b9 100644 --- a/homeassistant/components/netatmo.py +++ b/homeassistant/components/netatmo.py @@ -12,17 +12,15 @@ from homeassistant.helpers import validate_config, discovery REQUIREMENTS = [ 'https://github.com/jabesq/netatmo-api-python/archive/' - 'v0.5.0.zip#lnetatmo==0.5.0'] + 'master.zip#lnetatmo==0.5.0'] _LOGGER = logging.getLogger(__name__) CONF_SECRET_KEY = 'secret_key' -DOMAIN = "netatmo" +DOMAIN = 'netatmo' NETATMO_AUTH = None -_LOGGER = logging.getLogger(__name__) - def setup(hass, config): """Setup the Netatmo devices.""" diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index a28a50d766f..9fb6ca1f842 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -41,7 +41,7 @@ PLATFORM_SCHEMA = vol.Schema({ NOTIFY_SERVICE_SCHEMA = vol.Schema({ vol.Required(ATTR_MESSAGE): cv.template, - vol.Optional(ATTR_TITLE, default=ATTR_TITLE_DEFAULT): cv.string, + vol.Optional(ATTR_TITLE): cv.string, vol.Optional(ATTR_TARGET): cv.string, vol.Optional(ATTR_DATA): dict, }) @@ -91,19 +91,22 @@ def setup(hass, config): def notify_message(notify_service, call): """Handle sending notification message service calls.""" + kwargs = {} message = call.data[ATTR_MESSAGE] + title = call.data.get(ATTR_TITLE) + + if title: + kwargs[ATTR_TITLE] = template.render(hass, title) - title = template.render( - hass, call.data.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)) if targets.get(call.service) is not None: - target = targets[call.service] + kwargs[ATTR_TARGET] = targets[call.service] else: - target = call.data.get(ATTR_TARGET) - message = template.render(hass, message) - data = call.data.get(ATTR_DATA) + kwargs[ATTR_TARGET] = call.data.get(ATTR_TARGET) - notify_service.send_message(message, title=title, target=target, - data=data) + kwargs[ATTR_MESSAGE] = template.render(hass, message) + kwargs[ATTR_DATA] = call.data.get(ATTR_DATA) + + notify_service.send_message(**kwargs) service_call_handler = partial(notify_message, notify_service) diff --git a/homeassistant/components/notify/aws_lambda.py b/homeassistant/components/notify/aws_lambda.py index 68f0de7a934..3e3003763ea 100644 --- a/homeassistant/components/notify/aws_lambda.py +++ b/homeassistant/components/notify/aws_lambda.py @@ -7,29 +7,30 @@ https://home-assistant.io/components/notify.aws_lambda/ import logging import json import base64 + import voluptuous as vol from homeassistant.const import ( CONF_PLATFORM, CONF_NAME) from homeassistant.components.notify import ( - ATTR_TARGET, BaseNotificationService) + ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ["boto3==1.3.1"] -CONF_REGION = "region_name" -CONF_ACCESS_KEY_ID = "aws_access_key_id" -CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" -CONF_PROFILE_NAME = "profile_name" -CONF_CONTEXT = "context" +CONF_REGION = 'region_name' +CONF_ACCESS_KEY_ID = 'aws_access_key_id' +CONF_SECRET_ACCESS_KEY = 'aws_secret_access_key' +CONF_PROFILE_NAME = 'profile_name' +CONF_CONTEXT = 'context' +ATTR_CREDENTIALS = 'credentials' -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): "aws_lambda", - vol.Optional(CONF_NAME): vol.Coerce(str), - vol.Optional(CONF_REGION, default="us-east-1"): vol.Coerce(str), - vol.Inclusive(CONF_ACCESS_KEY_ID, "credentials"): vol.Coerce(str), - vol.Inclusive(CONF_SECRET_ACCESS_KEY, "credentials"): vol.Coerce(str), - vol.Exclusive(CONF_PROFILE_NAME, "credentials"): vol.Coerce(str), +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_REGION, default="us-east-1"): cv.string, + vol.Inclusive(CONF_ACCESS_KEY_ID, ATTR_CREDENTIALS): cv.string, + vol.Inclusive(CONF_SECRET_ACCESS_KEY, ATTR_CREDENTIALS): cv.string, + vol.Exclusive(CONF_PROFILE_NAME, ATTR_CREDENTIALS): cv.string, vol.Optional(CONF_CONTEXT, default=dict()): vol.Coerce(dict) }) diff --git a/homeassistant/components/notify/aws_sns.py b/homeassistant/components/notify/aws_sns.py index dec72b18633..88233234eca 100644 --- a/homeassistant/components/notify/aws_sns.py +++ b/homeassistant/components/notify/aws_sns.py @@ -6,28 +6,30 @@ https://home-assistant.io/components/notify.aws_sns/ """ import logging import json + import voluptuous as vol from homeassistant.const import ( CONF_PLATFORM, CONF_NAME) from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TARGET, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_TARGET, PLATFORM_SCHEMA, + BaseNotificationService) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ["boto3==1.3.1"] -CONF_REGION = "region_name" -CONF_ACCESS_KEY_ID = "aws_access_key_id" -CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" -CONF_PROFILE_NAME = "profile_name" +CONF_REGION = 'region_name' +CONF_ACCESS_KEY_ID = 'aws_access_key_id' +CONF_SECRET_ACCESS_KEY = 'aws_secret_access_key' +CONF_PROFILE_NAME = 'profile_name' +ATTR_CREDENTIALS = 'credentials' -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): "aws_sns", - vol.Optional(CONF_NAME): vol.Coerce(str), - vol.Optional(CONF_REGION, default="us-east-1"): vol.Coerce(str), - vol.Inclusive(CONF_ACCESS_KEY_ID, "credentials"): vol.Coerce(str), - vol.Inclusive(CONF_SECRET_ACCESS_KEY, "credentials"): vol.Coerce(str), - vol.Exclusive(CONF_PROFILE_NAME, "credentials"): vol.Coerce(str) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_REGION, default="us-east-1"): cv.string, + vol.Inclusive(CONF_ACCESS_KEY_ID, ATTR_CREDENTIALS): cv.string, + vol.Inclusive(CONF_SECRET_ACCESS_KEY, ATTR_CREDENTIALS): cv.string, + vol.Exclusive(CONF_PROFILE_NAME, ATTR_CREDENTIALS): cv.string, }) @@ -76,5 +78,6 @@ class AWSSNS(BaseNotificationService): for k, v in kwargs.items() if v} for target in targets: self.client.publish(TargetArn=target, Message=message, - Subject=kwargs.get(ATTR_TITLE), + Subject=kwargs.get(ATTR_TITLE, + ATTR_TITLE_DEFAULT), MessageAttributes=message_attributes) diff --git a/homeassistant/components/notify/aws_sqs.py b/homeassistant/components/notify/aws_sqs.py index a600878cda7..a1ddbcea3dd 100644 --- a/homeassistant/components/notify/aws_sqs.py +++ b/homeassistant/components/notify/aws_sqs.py @@ -6,28 +6,29 @@ https://home-assistant.io/components/notify.aws_sqs/ """ import logging import json + import voluptuous as vol from homeassistant.const import ( CONF_PLATFORM, CONF_NAME) from homeassistant.components.notify import ( - ATTR_TARGET, BaseNotificationService) + ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ["boto3==1.3.1"] -CONF_REGION = "region_name" -CONF_ACCESS_KEY_ID = "aws_access_key_id" -CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" -CONF_PROFILE_NAME = "profile_name" +CONF_REGION = 'region_name' +CONF_ACCESS_KEY_ID = 'aws_access_key_id' +CONF_SECRET_ACCESS_KEY = 'aws_secret_access_key' +CONF_PROFILE_NAME = 'profile_name' +ATTR_CREDENTIALS = 'credentials' -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): "aws_sqs", - vol.Optional(CONF_NAME): vol.Coerce(str), - vol.Optional(CONF_REGION, default="us-east-1"): vol.Coerce(str), - vol.Inclusive(CONF_ACCESS_KEY_ID, "credentials"): vol.Coerce(str), - vol.Inclusive(CONF_SECRET_ACCESS_KEY, "credentials"): vol.Coerce(str), - vol.Exclusive(CONF_PROFILE_NAME, "credentials"): vol.Coerce(str) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_REGION, default="us-east-1"): cv.string, + vol.Inclusive(CONF_ACCESS_KEY_ID, ATTR_CREDENTIALS): cv.string, + vol.Inclusive(CONF_SECRET_ACCESS_KEY, ATTR_CREDENTIALS): cv.string, + vol.Exclusive(CONF_PROFILE_NAME, ATTR_CREDENTIALS): cv.string, }) diff --git a/homeassistant/components/notify/command_line.py b/homeassistant/components/notify/command_line.py index df77560c22b..9b637d71188 100644 --- a/homeassistant/components/notify/command_line.py +++ b/homeassistant/components/notify/command_line.py @@ -6,21 +6,25 @@ https://home-assistant.io/components/notify.command_line/ """ import logging import subprocess -from homeassistant.helpers import validate_config + +import voluptuous as vol + +from homeassistant.const import (CONF_COMMAND, CONF_NAME) from homeassistant.components.notify import ( - DOMAIN, BaseNotificationService) + BaseNotificationService, PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_NAME): cv.string, +}) + def get_service(hass, config): """Get the Command Line notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: ['command']}, - _LOGGER): - return None - - command = config['command'] + command = config[CONF_COMMAND] return CommandLineNotificationService(command) diff --git a/homeassistant/components/notify/ecobee.py b/homeassistant/components/notify/ecobee.py index 861d5439e4c..4ac4a9ca8db 100644 --- a/homeassistant/components/notify/ecobee.py +++ b/homeassistant/components/notify/ecobee.py @@ -5,16 +5,28 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.ecobee/ """ import logging + +import voluptuous as vol + from homeassistant.components import ecobee -from homeassistant.components.notify import BaseNotificationService +from homeassistant.components.notify import ( + BaseNotificationService, PLATFORM_SCHEMA) # NOQA +import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['ecobee'] _LOGGER = logging.getLogger(__name__) +CONF_INDEX = 'index' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_INDEX, default=0): cv.positive_int, +}) + + def get_service(hass, config): """Get the Ecobee notification service.""" - index = int(config['index']) if 'index' in config else 0 + index = config.get(CONF_INDEX) return EcobeeNotificationService(index) diff --git a/homeassistant/components/notify/file.py b/homeassistant/components/notify/file.py index 3d04bf13334..82ec2420df8 100644 --- a/homeassistant/components/notify/file.py +++ b/homeassistant/components/notify/file.py @@ -7,24 +7,28 @@ https://home-assistant.io/components/notify.file/ import logging import os +import voluptuous as vol + import homeassistant.util.dt as dt_util from homeassistant.components.notify import ( - ATTR_TITLE, DOMAIN, BaseNotificationService) -from homeassistant.helpers import validate_config + ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONF_FILENAME +import homeassistant.helpers.config_validation as cv + +CONF_TIMESTAMP = 'timestamp' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_FILENAME): cv.string, + vol.Optional(CONF_TIMESTAMP, default=False): cv.boolean, +}) _LOGGER = logging.getLogger(__name__) def get_service(hass, config): """Get the file notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: ['filename', - 'timestamp']}, - _LOGGER): - return None - - filename = config['filename'] - timestamp = config['timestamp'] + filename = config[CONF_FILENAME] + timestamp = config[CONF_TIMESTAMP] return FileNotificationService(hass, filename, timestamp) @@ -43,14 +47,13 @@ class FileNotificationService(BaseNotificationService): with open(self.filepath, 'a') as file: if os.stat(self.filepath).st_size == 0: title = '{} notifications (Log started: {})\n{}\n'.format( - kwargs.get(ATTR_TITLE), + kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), dt_util.utcnow().isoformat(), '-' * 80) file.write(title) - if self.add_timestamp == 1: + if self.add_timestamp: text = '{} {}\n'.format(dt_util.utcnow().isoformat(), message) - file.write(text) else: text = '{}\n'.format(message) - file.write(text) + file.write(text) diff --git a/homeassistant/components/notify/free_mobile.py b/homeassistant/components/notify/free_mobile.py index e12cc5893b8..e5209e06582 100644 --- a/homeassistant/components/notify/free_mobile.py +++ b/homeassistant/components/notify/free_mobile.py @@ -6,22 +6,25 @@ https://home-assistant.io/components/notify.free_mobile/ """ import logging -from homeassistant.components.notify import DOMAIN, BaseNotificationService +import voluptuous as vol + +from homeassistant.components.notify import ( + PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME -from homeassistant.helpers import validate_config +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['freesms==0.1.0'] +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, +}) + + def get_service(hass, config): """Get the Free Mobile SMS notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_USERNAME, - CONF_ACCESS_TOKEN]}, - _LOGGER): - return None - return FreeSMSNotificationService(config[CONF_USERNAME], config[CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/notify/gntp.py b/homeassistant/components/notify/gntp.py index 5b5d377e1ea..fa7db0d6e6e 100644 --- a/homeassistant/components/notify/gntp.py +++ b/homeassistant/components/notify/gntp.py @@ -7,8 +7,12 @@ https://home-assistant.io/components/notify.gntp/ import logging import os +import voluptuous as vol + from homeassistant.components.notify import ( - ATTR_TITLE, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONF_PASSWORD, CONF_PORT +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['gntp==1.0.3'] @@ -18,20 +22,37 @@ _GNTP_LOGGER = logging.getLogger('gntp') _GNTP_LOGGER.setLevel(logging.ERROR) +CONF_APP_NAME = 'app_name' +CONF_APP_ICON = 'app_icon' +CONF_HOSTNAME = 'hostname' + +DEFAULT_APP_NAME = 'HomeAssistant' +DEFAULT_HOST = 'localhost' +DEFAULT_PORT = 23053 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_APP_NAME, default=DEFAULT_APP_NAME): cv.string, + vol.Optional(CONF_APP_ICON): vol.Url, + vol.Optional(CONF_HOSTNAME, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + + def get_service(hass, config): """Get the GNTP notification service.""" - if config.get('app_icon') is None: + if config.get(CONF_APP_ICON) is None: icon_file = os.path.join(os.path.dirname(__file__), "..", "frontend", "www_static", "icons", "favicon-192x192.png") app_icon = open(icon_file, 'rb').read() else: - app_icon = config.get('app_icon') + app_icon = config.get(CONF_APP_ICON) - return GNTPNotificationService(config.get('app_name', 'HomeAssistant'), - config.get('app_icon', app_icon), - config.get('hostname', 'localhost'), - config.get('password'), - config.get('port', 23053)) + return GNTPNotificationService(config.get(CONF_APP_NAME), + app_icon, + config.get(CONF_HOSTNAME), + config.get(CONF_PASSWORD), + config.get(CONF_PORT)) # pylint: disable=too-few-public-methods @@ -59,5 +80,6 @@ class GNTPNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to a user.""" - self.gntp.notify(noteType="Notification", title=kwargs.get(ATTR_TITLE), + self.gntp.notify(noteType="Notification", + title=kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), description=message) diff --git a/homeassistant/components/notify/group.py b/homeassistant/components/notify/group.py index 522b231d8cf..0d480a9ddac 100644 --- a/homeassistant/components/notify/group.py +++ b/homeassistant/components/notify/group.py @@ -8,8 +8,9 @@ import collections import logging import voluptuous as vol -from homeassistant.const import (CONF_PLATFORM, CONF_NAME, ATTR_SERVICE) +from homeassistant.const import ATTR_SERVICE from homeassistant.components.notify import (DOMAIN, ATTR_MESSAGE, ATTR_DATA, + PLATFORM_SCHEMA, BaseNotificationService) import homeassistant.helpers.config_validation as cv @@ -17,9 +18,7 @@ _LOGGER = logging.getLogger(__name__) CONF_SERVICES = "services" -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): "group", - vol.Required(CONF_NAME): vol.Coerce(str), +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SERVICES): vol.All(cv.ensure_list, [{ vol.Required(ATTR_SERVICE): cv.slug, vol.Optional(ATTR_DATA): dict, diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 54727a60d3f..103ccc7885b 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -18,8 +18,8 @@ from homeassistant.const import (HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR, HTTP_UNAUTHORIZED, URL_ROOT) from homeassistant.util import ensure_unique_string from homeassistant.components.notify import ( - ATTR_TARGET, ATTR_TITLE, ATTR_DATA, BaseNotificationService, - PLATFORM_SCHEMA) + ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_DATA, + BaseNotificationService, PLATFORM_SCHEMA) from homeassistant.components.http import HomeAssistantView from homeassistant.components.frontend import add_manifest_json_key from homeassistant.helpers import config_validation as cv @@ -332,7 +332,7 @@ class HTML5NotificationService(BaseNotificationService): 'icon': '/static/icons/favicon-192x192.png', ATTR_TAG: tag, 'timestamp': (timestamp*1000), # Javascript ms since epoch - ATTR_TITLE: kwargs.get(ATTR_TITLE) + ATTR_TITLE: kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) } data = kwargs.get(ATTR_DATA) diff --git a/homeassistant/components/notify/instapush.py b/homeassistant/components/notify/instapush.py index 028afb32468..3a8f2d9ee0a 100644 --- a/homeassistant/components/notify/instapush.py +++ b/homeassistant/components/notify/instapush.py @@ -8,11 +8,25 @@ import json import logging import requests +import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TITLE, DOMAIN, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_API_KEY -from homeassistant.helpers import validate_config + + +CONF_APP_SECRET = 'app_secret' +CONF_EVENT = 'event' +CONF_TRACKER = 'tracker' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_APP_SECRET): cv.string, + vol.Required(CONF_EVENT): cv.string, + vol.Required(CONF_TRACKER): cv.string, +}) + _LOGGER = logging.getLogger(__name__) _RESOURCE = 'https://api.instapush.im/v1/' @@ -20,16 +34,8 @@ _RESOURCE = 'https://api.instapush.im/v1/' def get_service(hass, config): """Get the instapush notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_API_KEY, - 'app_secret', - 'event', - 'tracker']}, - _LOGGER): - return None - headers = {'x-instapush-appid': config[CONF_API_KEY], - 'x-instapush-appsecret': config['app_secret']} + 'x-instapush-appsecret': config[CONF_APP_SECRET]} try: response = requests.get(_RESOURCE + 'events/list', @@ -42,15 +48,16 @@ def get_service(hass, config): _LOGGER.error(response['msg']) return None - if len([app for app in response if app['title'] == config['event']]) == 0: + if len([app for app in response + if app['title'] == config[CONF_EVENT]]) == 0: _LOGGER.error( "No app match your given value. " "Please create an app at https://instapush.im") return None return InstapushNotificationService( - config[CONF_API_KEY], config['app_secret'], config['event'], - config['tracker']) + config[CONF_API_KEY], config[CONF_APP_SECRET], config[CONF_EVENT], + config[CONF_TRACKER]) # pylint: disable=too-few-public-methods @@ -70,7 +77,7 @@ class InstapushNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to a user.""" - title = kwargs.get(ATTR_TITLE) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = {"event": self._event, "trackers": {self._tracker: title + " : " + message}} diff --git a/homeassistant/components/notify/joaoapps_join.py b/homeassistant/components/notify/joaoapps_join.py index 67ecd493a06..1478c2330ed 100644 --- a/homeassistant/components/notify/joaoapps_join.py +++ b/homeassistant/components/notify/joaoapps_join.py @@ -7,8 +7,9 @@ https://home-assistant.io/components/notify.join/ import logging import voluptuous as vol from homeassistant.components.notify import ( - ATTR_DATA, ATTR_TITLE, BaseNotificationService) -from homeassistant.const import CONF_PLATFORM, CONF_NAME, CONF_API_KEY + ATTR_DATA, ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, + BaseNotificationService) +from homeassistant.const import CONF_API_KEY import homeassistant.helpers.config_validation as cv REQUIREMENTS = [ @@ -19,10 +20,8 @@ _LOGGER = logging.getLogger(__name__) CONF_DEVICE_ID = 'device_id' -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'joaoapps_join', +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_DEVICE_ID): cv.string, - vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_API_KEY): cv.string }) @@ -52,7 +51,7 @@ class JoinNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to a user.""" from pyjoin import send_notification - title = kwargs.get(ATTR_TITLE) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = kwargs.get(ATTR_DATA) or {} send_notification(device_id=self._device_id, text=message, diff --git a/homeassistant/components/notify/message_bird.py b/homeassistant/components/notify/message_bird.py index 86bcfe79cd0..2e7f2f9bb07 100644 --- a/homeassistant/components/notify/message_bird.py +++ b/homeassistant/components/notify/message_bird.py @@ -6,26 +6,22 @@ https://home-assistant.io/components/notify.message_bird/ """ import logging -from homeassistant.components.notify import ( - ATTR_TARGET, DOMAIN, BaseNotificationService) -from homeassistant.const import CONF_API_KEY -from homeassistant.helpers import validate_config +import voluptuous as vol -CONF_SENDER = 'sender' +import homeassistant.helpers.config_validation as cv +from homeassistant.components.notify import ( + ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONF_API_KEY, CONF_SENDER _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['messagebird==1.2.0'] -def is_valid_sender(sender): - """Test if the sender config option is valid.""" - length = len(sender) - if length > 1: - if sender[0] == '+': - return sender[1:].isdigit() - elif length <= 11: - return sender.isalpha() - return False +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_SENDER, default='HA'): + vol.Match(r"^(\+?[1-9]\d{1,14}|\w{1,11})$"), +}) # pylint: disable=unused-argument @@ -33,17 +29,6 @@ def get_service(hass, config): """Get the MessageBird notification service.""" import messagebird - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_API_KEY]}, - _LOGGER): - return None - - sender = config.get(CONF_SENDER, 'HA') - if not is_valid_sender(sender): - _LOGGER.error('Sender is invalid: It must be a phone number or ' - 'a string not longer than 11 characters.') - return None - client = messagebird.Client(config[CONF_API_KEY]) try: # validates the api key @@ -52,7 +37,7 @@ def get_service(hass, config): _LOGGER.error('The specified MessageBird API key is invalid.') return None - return MessageBirdNotificationService(sender, client) + return MessageBirdNotificationService(config.get(CONF_SENDER), client) # pylint: disable=too-few-public-methods diff --git a/homeassistant/components/notify/nma.py b/homeassistant/components/notify/nma.py index f37f5ca8bd0..ffa4ae229c7 100644 --- a/homeassistant/components/notify/nma.py +++ b/homeassistant/components/notify/nma.py @@ -8,23 +8,24 @@ import logging import xml.etree.ElementTree as ET import requests +import voluptuous as vol from homeassistant.components.notify import ( - ATTR_TITLE, DOMAIN, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_API_KEY -from homeassistant.helpers import validate_config +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) _RESOURCE = 'https://www.notifymyandroid.com/publicapi/' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, +}) + + def get_service(hass, config): """Get the NMA notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_API_KEY]}, - _LOGGER): - return None - response = requests.get(_RESOURCE + 'verify', params={"apikey": config[CONF_API_KEY]}) tree = ET.fromstring(response.content) @@ -49,7 +50,7 @@ class NmaNotificationService(BaseNotificationService): data = { "apikey": self._api_key, "application": 'home-assistant', - "event": kwargs.get(ATTR_TITLE), + "event": kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), "description": message, "priority": 0, } diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index 20a6daebf05..d5402548508 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -6,24 +6,29 @@ https://home-assistant.io/components/notify.pushbullet/ """ import logging +import voluptuous as vol + from homeassistant.components.notify import ( - ATTR_TARGET, ATTR_TITLE, BaseNotificationService) + ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, + BaseNotificationService) from homeassistant.const import CONF_API_KEY +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['pushbullet.py==0.10.0'] +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, +}) + + # pylint: disable=unused-argument def get_service(hass, config): """Get the PushBullet notification service.""" from pushbullet import PushBullet from pushbullet import InvalidKeyError - if CONF_API_KEY not in config: - _LOGGER.error("Unable to find config key '%s'", CONF_API_KEY) - return None - try: pushbullet = PushBullet(config[CONF_API_KEY]) except InvalidKeyError: @@ -73,7 +78,7 @@ class PushBulletNotificationService(BaseNotificationService): call which doesn't require a push object. """ targets = kwargs.get(ATTR_TARGET) - title = kwargs.get(ATTR_TITLE) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) refreshed = False if not targets: diff --git a/homeassistant/components/notify/pushetta.py b/homeassistant/components/notify/pushetta.py index 234c8978452..ab304dc6514 100644 --- a/homeassistant/components/notify/pushetta.py +++ b/homeassistant/components/notify/pushetta.py @@ -6,37 +6,42 @@ https://home-assistant.io/components/notify.pushetta/ """ import logging +import voluptuous as vol + from homeassistant.components.notify import ( - ATTR_TITLE, DOMAIN, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_API_KEY -from homeassistant.helpers import validate_config +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) - REQUIREMENTS = ['pushetta==1.0.15'] +CONF_CHANNEL_NAME = 'channel_name' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_CHANNEL_NAME): cv.string, +}) + + def get_service(hass, config): """Get the Pushetta notification service.""" from pushetta import Pushetta, exceptions - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_API_KEY, 'channel_name']}, - _LOGGER): - return None - try: pushetta = Pushetta(config[CONF_API_KEY]) - pushetta.pushMessage(config['channel_name'], "Home Assistant started") + pushetta.pushMessage(config[CONF_CHANNEL_NAME], + "Home Assistant started") except exceptions.TokenValidationError: _LOGGER.error("Please check your access token") return None except exceptions.ChannelNotFoundError: - _LOGGER.error("Channel '%s' not found", config['channel_name']) + _LOGGER.error("Channel '%s' not found", config[CONF_CHANNEL_NAME]) return None return PushettaNotificationService(config[CONF_API_KEY], - config['channel_name']) + config[CONF_CHANNEL_NAME]) # pylint: disable=too-few-public-methods @@ -52,6 +57,6 @@ class PushettaNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to a user.""" - title = kwargs.get(ATTR_TITLE) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) self.pushetta.pushMessage(self._channel_name, "{} {}".format(title, message)) diff --git a/homeassistant/components/notify/pushover.py b/homeassistant/components/notify/pushover.py index 5ded1ebe778..c0a067fe918 100644 --- a/homeassistant/components/notify/pushover.py +++ b/homeassistant/components/notify/pushover.py @@ -9,7 +9,8 @@ import logging import voluptuous as vol from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TARGET, ATTR_DATA, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_TARGET, ATTR_DATA, + BaseNotificationService) from homeassistant.const import CONF_API_KEY import homeassistant.helpers.config_validation as cv @@ -17,8 +18,10 @@ REQUIREMENTS = ['python-pushover==0.2'] _LOGGER = logging.getLogger(__name__) +CONF_USER_KEY = 'user_key' + PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ - vol.Required('user_key'): cv.string, + vol.Required(CONF_USER_KEY): cv.string, vol.Required(CONF_API_KEY): cv.string, }) @@ -29,7 +32,7 @@ def get_service(hass, config): from pushover import InitError try: - return PushoverNotificationService(config['user_key'], + return PushoverNotificationService(config[CONF_USER_KEY], config[CONF_API_KEY]) except InitError: _LOGGER.error( @@ -56,7 +59,7 @@ class PushoverNotificationService(BaseNotificationService): # Make a copy and use empty dict if necessary data = dict(kwargs.get(ATTR_DATA) or {}) - data['title'] = kwargs.get(ATTR_TITLE) + data['title'] = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) target = kwargs.get(ATTR_TARGET) if target is not None: diff --git a/homeassistant/components/notify/rest.py b/homeassistant/components/notify/rest.py index 5cc556a1957..0a82b8d5d72 100644 --- a/homeassistant/components/notify/rest.py +++ b/homeassistant/components/notify/rest.py @@ -10,7 +10,8 @@ import requests import voluptuous as vol from homeassistant.components.notify import ( - ATTR_TARGET, ATTR_TITLE, BaseNotificationService, PLATFORM_SCHEMA) + ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, BaseNotificationService, + PLATFORM_SCHEMA) from homeassistant.const import (CONF_RESOURCE, CONF_METHOD, CONF_NAME) import homeassistant.helpers.config_validation as cv @@ -71,7 +72,8 @@ class RestNotificationService(BaseNotificationService): } if self._title_param_name is not None: - data[self._title_param_name] = kwargs.get(ATTR_TITLE) + data[self._title_param_name] = kwargs.get(ATTR_TITLE, + ATTR_TITLE_DEFAULT) if self._target_param_name is not None: data[self._target_param_name] = kwargs.get(ATTR_TARGET) diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index 894b35a85d4..42921e2be2c 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -6,24 +6,30 @@ https://home-assistant.io/components/notify.sendgrid/ """ import logging -from homeassistant.components.notify import ( - ATTR_TITLE, DOMAIN, BaseNotificationService) -from homeassistant.helpers import validate_config +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import (CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['sendgrid==3.4.0'] -REQUIREMENTS = ['sendgrid==3.2.10'] _LOGGER = logging.getLogger(__name__) +# pylint: disable=no-value-for-parameter +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_SENDER): vol.Email(), + vol.Required(CONF_RECIPIENT): vol.Email(), +}) + def get_service(hass, config): """Get the SendGrid notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: ['api_key', 'sender', 'recipient']}, - _LOGGER): - return None - - api_key = config['api_key'] - sender = config['sender'] - recipient = config['recipient'] + api_key = config.get(CONF_API_KEY) + sender = config.get(CONF_SENDER) + recipient = config.get(CONF_RECIPIENT) return SendgridNotificationService(api_key, sender, recipient) @@ -44,7 +50,7 @@ class SendgridNotificationService(BaseNotificationService): def send_message(self, message='', **kwargs): """Send an email to a user via SendGrid.""" - subject = kwargs.get(ATTR_TITLE) + subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = { "personalizations": [ diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index 39ca0197d0f..780a27b9795 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -6,27 +6,33 @@ https://home-assistant.io/components/notify.slack/ """ import logging -from homeassistant.components.notify import DOMAIN, BaseNotificationService -from homeassistant.const import CONF_API_KEY -from homeassistant.helpers import validate_config +import voluptuous as vol + +from homeassistant.components.notify import ( + PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONF_API_KEY +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['slacker==0.9.25'] -REQUIREMENTS = ['slacker==0.9.24'] _LOGGER = logging.getLogger(__name__) +CONF_CHANNEL = 'default_channel' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_CHANNEL): cv.string, +}) + # pylint: disable=unused-variable def get_service(hass, config): """Get the Slack notification service.""" import slacker - if not validate_config({DOMAIN: config}, - {DOMAIN: ['default_channel', CONF_API_KEY]}, - _LOGGER): - return None - try: return SlackNotificationService( - config['default_channel'], + config[CONF_CHANNEL], config[CONF_API_KEY]) except slacker.Error: @@ -52,14 +58,12 @@ class SlackNotificationService(BaseNotificationService): channel = kwargs.get('target') or self._default_channel data = kwargs.get('data') - if data: - attachments = data.get('attachments') - else: - attachments = None + attachments = data.get('attachments') if data else None try: self.slack.chat.post_message(channel, message, as_user=True, - attachments=attachments) - except slacker.Error: - _LOGGER.exception("Could not send slack notification") + attachments=attachments, + link_names=True) + except slacker.Error as err: + _LOGGER.error("Could not send slack notification. Error: %s", err) diff --git a/homeassistant/components/notify/smtp.py b/homeassistant/components/notify/smtp.py index 9ac73a49e3d..84aae3f2c8f 100644 --- a/homeassistant/components/notify/smtp.py +++ b/homeassistant/components/notify/smtp.py @@ -12,30 +12,36 @@ from email.mime.image import MIMEImage import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_PORT) + ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_DATA, PLATFORM_SCHEMA, + BaseNotificationService) +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_SENDER, CONF_RECIPIENT) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) ATTR_IMAGES = 'images' # optional embedded image file attachments CONF_STARTTLS = 'starttls' -CONF_SENDER = 'sender' -CONF_RECIPIENT = 'recipient' CONF_DEBUG = 'debug' CONF_SERVER = 'server' +DEFAULT_HOST = 'localhost' +DEFAULT_PORT = 25 +DEFAULT_DEBUG = False +DEFAULT_STARTTLS = False + +# pylint: disable=no-value-for-parameter PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_RECIPIENT): cv.string, - vol.Optional(CONF_SERVER, default='localhost'): cv.string, - vol.Optional(CONF_PORT, default=25): cv.port, - vol.Optional(CONF_SENDER): cv.string, - vol.Optional(CONF_STARTTLS, default=False): cv.boolean, + vol.Required(CONF_RECIPIENT): vol.Email(), + vol.Optional(CONF_SERVER, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SENDER): vol.Email(), + vol.Optional(CONF_STARTTLS, default=DEFAULT_STARTTLS): cv.boolean, vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_DEBUG, default=False): cv.boolean, + vol.Optional(CONF_DEBUG, default=DEFAULT_DEBUG): cv.boolean, }) @@ -95,16 +101,14 @@ class MailNotificationService(BaseNotificationService): except smtplib.socket.gaierror: _LOGGER.exception( "SMTP server not found (%s:%s). " - "Please check the IP address or hostname of your SMTP server.", + "Please check the IP address or hostname of your SMTP server", self._server, self._port) - return False except (smtplib.SMTPAuthenticationError, ConnectionRefusedError): _LOGGER.exception( "Login not possible. " - "Please check your setting and/or your credentials.") - + "Please check your setting and/or your credentials") return False finally: @@ -120,7 +124,7 @@ class MailNotificationService(BaseNotificationService): Will send plain text normally, or will build a multipart HTML message with inline image attachments if images config is defined. """ - subject = kwargs.get(ATTR_TITLE) + subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = kwargs.get(ATTR_DATA) if data: @@ -154,13 +158,13 @@ class MailNotificationService(BaseNotificationService): def _build_text_msg(message): """Build plaintext email.""" - _LOGGER.debug('Building plain text email.') + _LOGGER.debug('Building plain text email') return MIMEText(message) def _build_multipart_msg(message, images): """Build Multipart message with in-line images.""" - _LOGGER.debug('Building multipart email with embedded attachment(s).') + _LOGGER.debug('Building multipart email with embedded attachment(s)') msg = MIMEMultipart('related') msg_alt = MIMEMultipart('alternative') msg.attach(msg_alt) @@ -177,7 +181,7 @@ def _build_multipart_msg(message, images): msg.attach(attachment) attachment.add_header('Content-ID', '<{}>'.format(cid)) except FileNotFoundError: - _LOGGER.warning('Attachment %s not found. Skipping.', + _LOGGER.warning('Attachment %s not found. Skipping', atch_name) body_html = MIMEText(''.join(body_text), 'html') diff --git a/homeassistant/components/notify/syslog.py b/homeassistant/components/notify/syslog.py index 381a92394c3..792ed2ad631 100644 --- a/homeassistant/components/notify/syslog.py +++ b/homeassistant/components/notify/syslog.py @@ -6,63 +6,76 @@ https://home-assistant.io/components/notify.syslog/ """ import logging +import voluptuous as vol + from homeassistant.components.notify import ( - ATTR_TITLE, DOMAIN, BaseNotificationService) -from homeassistant.helpers import validate_config + ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) + + +CONF_FACILITY = 'facility' +CONF_OPTION = 'option' +CONF_PRIORITY = 'priority' + +SYSLOG_FACILITY = { + 'kernel': 'LOG_KERN', + 'user': 'LOG_USER', + 'mail': 'LOG_MAIL', + 'daemon': 'LOG_DAEMON', + 'auth': 'LOG_KERN', + 'LPR': 'LOG_LPR', + 'news': 'LOG_NEWS', + 'uucp': 'LOG_UUCP', + 'cron': 'LOG_CRON', + 'syslog': 'LOG_SYSLOG', + 'local0': 'LOG_LOCAL0', + 'local1': 'LOG_LOCAL1', + 'local2': 'LOG_LOCAL2', + 'local3': 'LOG_LOCAL3', + 'local4': 'LOG_LOCAL4', + 'local5': 'LOG_LOCAL5', + 'local6': 'LOG_LOCAL6', + 'local7': 'LOG_LOCAL7', +} + +SYSLOG_OPTION = { + 'pid': 'LOG_PID', + 'cons': 'LOG_CONS', + 'ndelay': 'LOG_NDELAY', + 'nowait': 'LOG_NOWAIT', + 'perror': 'LOG_PERROR', +} + +SYSLOG_PRIORITY = { + 5: 'LOG_EMERG', + 4: 'LOG_ALERT', + 3: 'LOG_CRIT', + 2: 'LOG_ERR', + 1: 'LOG_WARNING', + 0: 'LOG_NOTICE', + -1: 'LOG_INFO', + -2: 'LOG_DEBUG', +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_FACILITY, default='syslog'): + vol.In(SYSLOG_FACILITY.keys()), + vol.Optional(CONF_OPTION, default='pid'): vol.In(SYSLOG_OPTION.keys()), + vol.Optional(CONF_PRIORITY, default=-1): vol.In(SYSLOG_PRIORITY.keys()), +}) + _LOGGER = logging.getLogger(__name__) def get_service(hass, config): """Get the syslog notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: ['facility', 'option', 'priority']}, - _LOGGER): - return None - import syslog - _facility = { - 'kernel': syslog.LOG_KERN, - 'user': syslog.LOG_USER, - 'mail': syslog.LOG_MAIL, - 'daemon': syslog.LOG_DAEMON, - 'auth': syslog.LOG_KERN, - 'LPR': syslog.LOG_LPR, - 'news': syslog.LOG_NEWS, - 'uucp': syslog.LOG_UUCP, - 'cron': syslog.LOG_CRON, - 'syslog': syslog.LOG_SYSLOG, - 'local0': syslog.LOG_LOCAL0, - 'local1': syslog.LOG_LOCAL1, - 'local2': syslog.LOG_LOCAL2, - 'local3': syslog.LOG_LOCAL3, - 'local4': syslog.LOG_LOCAL4, - 'local5': syslog.LOG_LOCAL5, - 'local6': syslog.LOG_LOCAL6, - 'local7': syslog.LOG_LOCAL7, - }.get(config['facility'], 40) + facility = getattr(syslog, SYSLOG_FACILITY[config.get(CONF_FACILITY)]) + option = getattr(syslog, SYSLOG_OPTION[config.get(CONF_OPTION)]) + priority = getattr(syslog, SYSLOG_PRIORITY[config.get(CONF_PRIORITY)]) - _option = { - 'pid': syslog.LOG_PID, - 'cons': syslog.LOG_CONS, - 'ndelay': syslog.LOG_NDELAY, - 'nowait': syslog.LOG_NOWAIT, - 'perror': syslog.LOG_PERROR - }.get(config['option'], 10) - - _priority = { - 5: syslog.LOG_EMERG, - 4: syslog.LOG_ALERT, - 3: syslog.LOG_CRIT, - 2: syslog.LOG_ERR, - 1: syslog.LOG_WARNING, - 0: syslog.LOG_NOTICE, - -1: syslog.LOG_INFO, - -2: syslog.LOG_DEBUG - }.get(config['priority'], -1) - - return SyslogNotificationService(_facility, _option, _priority) + return SyslogNotificationService(facility, option, priority) # pylint: disable=too-few-public-methods @@ -80,7 +93,7 @@ class SyslogNotificationService(BaseNotificationService): """Send a message to a user.""" import syslog - title = kwargs.get(ATTR_TITLE) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) syslog.openlog(title, self._option, self._facility) syslog.syslog(self._priority, message) diff --git a/homeassistant/components/notify/telegram.py b/homeassistant/components/notify/telegram.py index 8da916eb1f3..cc8b284b974 100644 --- a/homeassistant/components/notify/telegram.py +++ b/homeassistant/components/notify/telegram.py @@ -7,14 +7,15 @@ https://home-assistant.io/components/notify.telegram/ import io import logging import urllib + import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_DATA, BaseNotificationService) -from homeassistant.const import (CONF_API_KEY, CONF_NAME, ATTR_LOCATION, - ATTR_LATITUDE, ATTR_LONGITUDE, CONF_PLATFORM) + ATTR_TITLE, ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import (CONF_API_KEY, ATTR_LOCATION, ATTR_LATITUDE, + ATTR_LONGITUDE) _LOGGER = logging.getLogger(__name__) @@ -23,12 +24,14 @@ REQUIREMENTS = ['python-telegram-bot==5.0.0'] ATTR_PHOTO = "photo" ATTR_DOCUMENT = "document" ATTR_CAPTION = "caption" +ATTR_URL = 'url' +ATTR_FILE = 'file' +ATTR_USERNAME = 'username' +ATTR_PASSWORD = 'password' CONF_CHAT_ID = 'chat_id' -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): "telegram", - vol.Optional(CONF_NAME): cv.string, +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_CHAT_ID): cv.string, }) @@ -106,10 +109,18 @@ class TelegramNotificationService(BaseNotificationService): elif data is not None and ATTR_DOCUMENT in data: return self.send_document(data.get(ATTR_DOCUMENT)) + if title: + text = '{} {}'.format(title, message) + else: + text = message + + parse_mode = telegram.parsemode.ParseMode.MARKDOWN + # send message try: self.bot.sendMessage(chat_id=self._chat_id, - text=title + " " + message) + text=text, + parse_mode=parse_mode) except telegram.error.TelegramError: _LOGGER.exception("Error sending message.") return @@ -117,11 +128,16 @@ class TelegramNotificationService(BaseNotificationService): def send_photo(self, data): """Send a photo.""" import telegram - caption = data.pop(ATTR_CAPTION, None) + caption = data.get(ATTR_CAPTION) # send photo try: - photo = load_data(**data) + photo = load_data( + url=data.get(ATTR_URL), + file=data.get(ATTR_FILE), + username=data.get(ATTR_USERNAME), + password=data.get(ATTR_PASSWORD), + ) self.bot.sendPhoto(chat_id=self._chat_id, photo=photo, caption=caption) except telegram.error.TelegramError: @@ -131,11 +147,16 @@ class TelegramNotificationService(BaseNotificationService): def send_document(self, data): """Send a document.""" import telegram - caption = data.pop(ATTR_CAPTION, None) + caption = data.get(ATTR_CAPTION) # send photo try: - document = load_data(**data) + document = load_data( + url=data.get(ATTR_URL), + file=data.get(ATTR_FILE), + username=data.get(ATTR_USERNAME), + password=data.get(ATTR_PASSWORD), + ) self.bot.sendDocument(chat_id=self._chat_id, document=document, caption=caption) except telegram.error.TelegramError: diff --git a/homeassistant/components/notify/twilio_sms.py b/homeassistant/components/notify/twilio_sms.py index f7700240b67..ddcf8849b78 100644 --- a/homeassistant/components/notify/twilio_sms.py +++ b/homeassistant/components/notify/twilio_sms.py @@ -6,27 +6,29 @@ https://home-assistant.io/components/notify.twilio_sms/ """ import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TARGET, DOMAIN, BaseNotificationService) -from homeassistant.helpers import validate_config + ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService) _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ["twilio==5.4.0"] + CONF_ACCOUNT_SID = "account_sid" CONF_AUTH_TOKEN = "auth_token" CONF_FROM_NUMBER = "from_number" +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ACCOUNT_SID): cv.string, + vol.Required(CONF_AUTH_TOKEN): cv.string, + vol.Required(CONF_FROM_NUMBER): vol.Match(r"^\+?[1-9]\d{1,14}$"), +}) + def get_service(hass, config): """Get the Twilio SMS notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_ACCOUNT_SID, - CONF_AUTH_TOKEN, - CONF_FROM_NUMBER]}, - _LOGGER): - return None - # pylint: disable=import-error from twilio.rest import TwilioRestClient diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py index 9284c4fac93..bafdc2403be 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -6,9 +6,12 @@ https://home-assistant.io/components/notify.twitter/ """ import logging -from homeassistant.components.notify import DOMAIN, BaseNotificationService +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.notify import (PLATFORM_SCHEMA, + BaseNotificationService) from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.helpers import validate_config _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['TwitterAPI==2.4.2'] @@ -17,16 +20,16 @@ CONF_CONSUMER_KEY = "consumer_key" CONF_CONSUMER_SECRET = "consumer_secret" CONF_ACCESS_TOKEN_SECRET = "access_token_secret" +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_CONSUMER_KEY): cv.string, + vol.Required(CONF_CONSUMER_SECRET): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Required(CONF_ACCESS_TOKEN_SECRET): cv.string, +}) + def get_service(hass, config): """Get the Twitter notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_CONSUMER_KEY, CONF_CONSUMER_SECRET, - CONF_ACCESS_TOKEN, - CONF_ACCESS_TOKEN_SECRET]}, - _LOGGER): - return None - return TwitterNotificationService(config[CONF_CONSUMER_KEY], config[CONF_CONSUMER_SECRET], config[CONF_ACCESS_TOKEN], diff --git a/homeassistant/components/notify/webostv.py b/homeassistant/components/notify/webostv.py index 34463dc6e45..e8276255925 100644 --- a/homeassistant/components/notify/webostv.py +++ b/homeassistant/components/notify/webostv.py @@ -6,33 +6,30 @@ https://home-assistant.io/components/notify.webostv/ """ import logging -from homeassistant.components.notify import (BaseNotificationService, DOMAIN) -from homeassistant.const import (CONF_HOST, CONF_NAME) -from homeassistant.helpers import validate_config +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.components.notify import (BaseNotificationService, + PLATFORM_SCHEMA) +from homeassistant.const import CONF_HOST + +_LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['https://github.com/TheRealLink/pylgtv' '/archive/v0.1.2.zip' '#pylgtv==0.1.2'] -_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, +}) def get_service(hass, config): """Return the notify service.""" - if not validate_config({DOMAIN: config}, {DOMAIN: [CONF_HOST, CONF_NAME]}, - _LOGGER): - return None - - host = config.get(CONF_HOST, None) - - if not host: - _LOGGER.error('No host provided.') - return None - from pylgtv import WebOsClient from pylgtv import PyLGTVPairException - client = WebOsClient(host) + client = WebOsClient(config.get(CONF_HOST)) try: client.register() diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index 68c0ce2979f..ed64a8b4e07 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -6,30 +6,39 @@ https://home-assistant.io/components/notify.xmpp/ """ import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TITLE, DOMAIN, BaseNotificationService) -from homeassistant.helpers import validate_config + ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONF_PASSWORD, CONF_SENDER, CONF_RECIPIENT REQUIREMENTS = ['sleekxmpp==1.3.1', 'dnspython3==1.12.0', 'pyasn1==0.1.9', 'pyasn1-modules==0.0.8'] + +CONF_TLS = 'tls' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SENDER): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_RECIPIENT): cv.string, + vol.Optional(CONF_TLS, default=True): cv.boolean, +}) + + _LOGGER = logging.getLogger(__name__) def get_service(hass, config): """Get the Jabber (XMPP) notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: ['sender', 'password', 'recipient']}, - _LOGGER): - return None - return XmppNotificationService( config.get('sender'), config.get('password'), config.get('recipient'), - config.get('tls', True)) + config.get('tls')) # pylint: disable=too-few-public-methods @@ -45,7 +54,7 @@ class XmppNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to a user.""" - title = kwargs.get(ATTR_TITLE) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = "{}: {}".format(title, message) if title else message send_message(self._sender + '/home-assistant', self._password, diff --git a/homeassistant/components/octoprint.py b/homeassistant/components/octoprint.py index bd90e67d0df..871f81759e0 100644 --- a/homeassistant/components/octoprint.py +++ b/homeassistant/components/octoprint.py @@ -5,37 +5,41 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/octoprint/ """ import logging - import time + import requests +import voluptuous as vol -from homeassistant.const import CONF_API_KEY, CONF_HOST -from homeassistant.helpers import validate_config, discovery - -DOMAIN = "octoprint" -OCTOPRINT = None +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONTENT_TYPE_JSON +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DISCOVER_SENSORS = 'octoprint.sensors' DISCOVER_BINARY_SENSORS = 'octoprint.binary_sensor' +DISCOVER_SENSORS = 'octoprint.sensors' +DOMAIN = 'octoprint' + +OCTOPRINT = None + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_HOST): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) def setup(hass, config): """Set up OctoPrint API.""" - if not validate_config(config, {DOMAIN: [CONF_API_KEY], - DOMAIN: [CONF_HOST]}, - _LOGGER): - return False - - base_url = config[DOMAIN][CONF_HOST] + "/api/" + base_url = 'http://{}/api/'.format(config[DOMAIN][CONF_HOST]) api_key = config[DOMAIN][CONF_API_KEY] global OCTOPRINT try: OCTOPRINT = OctoPrintAPI(base_url, api_key) - OCTOPRINT.get("printer") - OCTOPRINT.get("job") + OCTOPRINT.get('printer') + OCTOPRINT.get('job') except requests.exceptions.RequestException as conn_err: _LOGGER.error("Error setting up OctoPrint API: %r", conn_err) return False @@ -55,7 +59,7 @@ class OctoPrintAPI(object): def __init__(self, api_url, key): """Initialize OctoPrint API and set headers needed later.""" self.api_url = api_url - self.headers = {'content-type': 'application/json', + self.headers = {'content-type': CONTENT_TYPE_JSON, 'X-Api-Key': key} self.printer_last_reading = [{}, None] self.job_last_reading = [{}, None] diff --git a/homeassistant/components/pilight.py b/homeassistant/components/pilight.py index 07771acee00..764b972d393 100644 --- a/homeassistant/components/pilight.py +++ b/homeassistant/components/pilight.py @@ -4,7 +4,6 @@ Component to create an interface to a Pilight daemon (https://pilight.org/). For more details about this component, please refer to the documentation at https://home-assistant.io/components/pilight/ """ -# pylint: disable=import-error import logging import socket @@ -12,46 +11,49 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ensure_list -from homeassistant.const import EVENT_HOMEASSISTANT_START -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT, + CONF_WHITELIST) REQUIREMENTS = ['pilight==0.0.2'] -DOMAIN = "pilight" +_LOGGER = logging.getLogger(__name__) + +ATTR_PROTOCOL = 'protocol' + +DEFAULT_HOST = '127.0.0.1' +DEFAULT_PORT = 5000 +DOMAIN = 'pilight' + EVENT = 'pilight_received' -SERVICE_NAME = 'send' - -CONF_WHITELIST = 'whitelist' - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_HOST, default='127.0.0.1'): cv.string, - vol.Required(CONF_PORT, default=5000): vol.Coerce(int), - vol.Optional(CONF_WHITELIST): {cv.string: [cv.string]} - }), -}, extra=vol.ALLOW_EXTRA) # The pilight code schema depends on the protocol # Thus only require to have the protocol information -ATTR_PROTOCOL = 'protocol' RF_CODE_SCHEMA = vol.Schema({vol.Required(ATTR_PROTOCOL): cv.string}, extra=vol.ALLOW_EXTRA) +SERVICE_NAME = 'send' -_LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_WHITELIST, default={}): {cv.string: [cv.string]} + }), +}, extra=vol.ALLOW_EXTRA) def setup(hass, config): """Setup the pilight component.""" from pilight import pilight + host = config[DOMAIN][CONF_HOST] + port = config[DOMAIN][CONF_PORT] + try: - pilight_client = pilight.Client(host=config[DOMAIN][CONF_HOST], - port=config[DOMAIN][CONF_PORT]) + pilight_client = pilight.Client(host=host, port=port) except (socket.error, socket.timeout) as err: - _LOGGER.error( - "Unable to connect to %s on port %s: %s", - config[CONF_HOST], config[CONF_PORT], err) + _LOGGER.error("Unable to connect to %s on port %s: %s", + host, port, err) return False # Start / stop pilight-daemon connection with HA start/stop @@ -74,7 +76,7 @@ def setup(hass, config): # Patch data because of bug: # https://github.com/pilight/pilight/issues/296 # Protocol has to be in a list otherwise segfault in pilight-daemon - message_data["protocol"] = ensure_list(message_data["protocol"]) + message_data['protocol'] = ensure_list(message_data['protocol']) try: pilight_client.send_code(message_data) @@ -86,7 +88,7 @@ def setup(hass, config): # Publish received codes on the HA event bus # A whitelist of codes to be published in the event bus - whitelist = config[DOMAIN].get('whitelist', False) + whitelist = config[DOMAIN].get(CONF_WHITELIST) def handle_received_code(data): """Called when RF codes are received.""" diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 6e3e2db064d..671623ec564 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -92,7 +92,8 @@ class States(Base): # type: ignore else: dbstate.domain = state.domain dbstate.state = state.state - dbstate.attributes = json.dumps(dict(state.attributes)) + dbstate.attributes = json.dumps(dict(state.attributes), + cls=JSONEncoder) dbstate.last_changed = state.last_changed dbstate.last_updated = state.last_updated diff --git a/homeassistant/components/rollershutter/demo.py b/homeassistant/components/rollershutter/demo.py index 31915019c5e..6799d062e43 100644 --- a/homeassistant/components/rollershutter/demo.py +++ b/homeassistant/components/rollershutter/demo.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ from homeassistant.components.rollershutter import RollershutterDevice -from homeassistant.const import EVENT_TIME_CHANGED from homeassistant.helpers.event import track_utc_time_change @@ -27,7 +26,7 @@ class DemoRollershutter(RollershutterDevice): self._name = name self._position = position self._moving_up = True - self._listener = None + self._unsub_listener = None @property def name(self): @@ -70,15 +69,15 @@ class DemoRollershutter(RollershutterDevice): def stop(self, **kwargs): """Stop the roller shutter.""" - if self._listener is not None: - self.hass.bus.remove_listener(EVENT_TIME_CHANGED, self._listener) - self._listener = None + if self._unsub_listener is not None: + self._unsub_listener() + self._unsub_listener = None def _listen(self): """Listen for changes.""" - if self._listener is None: - self._listener = track_utc_time_change(self.hass, - self._time_changed) + if self._unsub_listener is None: + self._unsub_listener = track_utc_time_change(self.hass, + self._time_changed) def _time_changed(self, now): """Track time changes.""" diff --git a/homeassistant/components/rollershutter/homematic.py b/homeassistant/components/rollershutter/homematic.py deleted file mode 100644 index 613d7884919..00000000000 --- a/homeassistant/components/rollershutter/homematic.py +++ /dev/null @@ -1,102 +0,0 @@ -""" -The homematic rollershutter platform. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/rollershutter.homematic/ - -Important: For this platform to work the homematic component has to be -properly configured. -""" - -import logging -from homeassistant.const import (STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN) -from homeassistant.components.rollershutter import RollershutterDevice,\ - ATTR_CURRENT_POSITION -import homeassistant.components.homematic as homematic - - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['homematic'] - - -def setup_platform(hass, config, add_callback_devices, discovery_info=None): - """Setup the platform.""" - if discovery_info is None: - return - - return homematic.setup_hmdevice_discovery_helper(HMRollershutter, - discovery_info, - add_callback_devices) - - -# pylint: disable=abstract-method -class HMRollershutter(homematic.HMDevice, RollershutterDevice): - """Represents a Homematic Rollershutter in Home Assistant.""" - - @property - def current_position(self): - """ - Return current position of rollershutter. - - None is unknown, 0 is closed, 100 is fully open. - """ - if self.available: - return int((1 - self._hm_get_state()) * 100) - return None - - def move_position(self, **kwargs): - """Move the roller shutter to a specific position.""" - if self.available: - if ATTR_CURRENT_POSITION in kwargs: - position = float(kwargs[ATTR_CURRENT_POSITION]) - position = min(100, max(0, position)) - level = (100 - position) / 100.0 - self._hmdevice.set_level(level, self._channel) - - @property - def state(self): - """Return the state of the rollershutter.""" - current = self.current_position - if current is None: - return STATE_UNKNOWN - - return STATE_CLOSED if current == 100 else STATE_OPEN - - def move_up(self, **kwargs): - """Move the rollershutter up.""" - if self.available: - self._hmdevice.move_up(self._channel) - - def move_down(self, **kwargs): - """Move the rollershutter down.""" - if self.available: - self._hmdevice.move_down(self._channel) - - def stop(self, **kwargs): - """Stop the device if in motion.""" - if self.available: - self._hmdevice.stop(self._channel) - - def _check_hm_to_ha_object(self): - """Check if possible to use the HM Object as this HA type.""" - from pyhomematic.devicetypes.actors import Blind - - # Check compatibility from HMDevice - if not super()._check_hm_to_ha_object(): - return False - - # Check if the homematic device is correct for this HA device - if isinstance(self._hmdevice, Blind): - return True - - _LOGGER.critical("This %s can't be use as rollershutter!", self._name) - return False - - def _init_data_struct(self): - """Generate a data dict (self._data) from hm metadata.""" - super()._init_data_struct() - - # Add state to data dict - self._state = "LEVEL" - self._data.update({self._state: STATE_UNKNOWN}) diff --git a/homeassistant/components/sensor/bitcoin.py b/homeassistant/components/sensor/bitcoin.py index 0d859314bb4..51b5f9bba3b 100644 --- a/homeassistant/components/sensor/bitcoin.py +++ b/homeassistant/components/sensor/bitcoin.py @@ -17,6 +17,16 @@ from homeassistant.util import Throttle REQUIREMENTS = ['blockchain==1.3.3'] +_LOGGER = logging.getLogger(__name__) + +CONF_CURRENCY = 'currency' + +DEFAULT_CURRENCY = 'USD' + +ICON = 'mdi:currency-btc' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) + OPTION_TYPES = { 'exchangerate': ['Exchange rate (1 BTC)', None], 'trade_volume_btc': ['Trade volume', 'BTC'], @@ -41,20 +51,12 @@ OPTION_TYPES = { 'market_price_usd': ['Market price', 'USD'] } -ICON = 'mdi:currency-btc' -CONF_CURRENCY = 'currency' - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_DISPLAY_OPTIONS, default=[]): - [vol.In(OPTION_TYPES)], - vol.Optional(CONF_CURRENCY, default='USD'): cv.string, + vol.All(cv.ensure_list, [vol.In(OPTION_TYPES)]), + vol.Optional(CONF_CURRENCY, default=DEFAULT_CURRENCY): cv.string, }) -_LOGGER = logging.getLogger(__name__) - -# Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) - def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Bitcoin sensors.""" @@ -63,8 +65,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): currency = config.get(CONF_CURRENCY) if currency not in exchangerates.get_ticker(): - _LOGGER.error('Currency "%s" is not available. Using "USD"', currency) - currency = 'USD' + _LOGGER.warning('Currency "%s" is not available. Using "USD"', + currency) + currency = DEFAULT_CURRENCY data = BitcoinData() dev = [] diff --git a/homeassistant/components/sensor/bloomsky.py b/homeassistant/components/sensor/bloomsky.py index a9d2c0c6631..1026e2a92db 100644 --- a/homeassistant/components/sensor/bloomsky.py +++ b/homeassistant/components/sensor/bloomsky.py @@ -6,58 +6,64 @@ https://home-assistant.io/components/sensor.bloomsky/ """ import logging -from homeassistant.const import TEMP_FAHRENHEIT +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (TEMP_FAHRENHEIT, CONF_MONITORED_CONDITIONS) from homeassistant.helpers.entity import Entity from homeassistant.loader import get_component +import homeassistant.helpers.config_validation as cv -DEPENDENCIES = ["bloomsky"] +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['bloomsky'] # These are the available sensors -SENSOR_TYPES = ["Temperature", - "Humidity", - "Pressure", - "Luminance", - "UVIndex", - "Voltage"] +SENSOR_TYPES = ['Temperature', + 'Humidity', + 'Pressure', + 'Luminance', + 'UVIndex', + 'Voltage'] # Sensor units - these do not currently align with the API documentation -SENSOR_UNITS = {"Temperature": TEMP_FAHRENHEIT, - "Humidity": "%", - "Pressure": "inHg", - "Luminance": "cd/m²", - "Voltage": "mV"} +SENSOR_UNITS = {'Temperature': TEMP_FAHRENHEIT, + 'Humidity': '%', + 'Pressure': 'inHg', + 'Luminance': 'cd/m²', + 'Voltage': 'mV'} # Which sensors to format numerically -FORMAT_NUMBERS = ["Temperature", "Pressure", "Voltage"] +FORMAT_NUMBERS = ['Temperature', 'Pressure', 'Voltage'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the available BloomSky weather sensors.""" - logger = logging.getLogger(__name__) bloomsky = get_component('bloomsky') - sensors = config.get('monitored_conditions', SENSOR_TYPES) + # Default needed in case of discovery + sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES) for device in bloomsky.BLOOMSKY.devices.values(): for variable in sensors: - if variable in SENSOR_TYPES: - add_devices([BloomSkySensor(bloomsky.BLOOMSKY, - device, - variable)]) - else: - logger.error("Cannot find definition for device: %s", variable) + add_devices([BloomSkySensor(bloomsky.BLOOMSKY, device, variable)]) class BloomSkySensor(Entity): """Representation of a single sensor in a BloomSky device.""" def __init__(self, bs, device, sensor_name): - """Initialize a bloomsky sensor.""" + """Initialize a BloomSky sensor.""" self._bloomsky = bs - self._device_id = device["DeviceID"] + self._device_id = device['DeviceID'] self._sensor_name = sensor_name - self._name = "{} {}".format(device["DeviceName"], sensor_name) - self._unique_id = "bloomsky_sensor {}".format(self._name) + self._name = '{} {}'.format(device['DeviceName'], sensor_name) + self._unique_id = 'bloomsky_sensor {}'.format(self._name) self.update() @property @@ -85,9 +91,9 @@ class BloomSkySensor(Entity): self._bloomsky.refresh_devices() state = \ - self._bloomsky.devices[self._device_id]["Data"][self._sensor_name] + self._bloomsky.devices[self._device_id]['Data'][self._sensor_name] if self._sensor_name in FORMAT_NUMBERS: - self._state = "{0:.2f}".format(state) + self._state = '{0:.2f}'.format(state) else: self._state = state diff --git a/homeassistant/components/sensor/coinmarketcap.py b/homeassistant/components/sensor/coinmarketcap.py new file mode 100644 index 00000000000..83adcac7fea --- /dev/null +++ b/homeassistant/components/sensor/coinmarketcap.py @@ -0,0 +1,125 @@ +""" +Details about crypto currencies from CoinMarketCap. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.coinmarketcap/ +""" +import logging +from datetime import timedelta +import json +from urllib.error import HTTPError + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['coinmarketcap==2.0.1'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_24H_VOLUME_USD = '24h_volume_usd' +ATTR_AVAILABLE_SUPPLY = 'available_supply' +ATTR_MARKET_CAP = 'market_cap_usd' +ATTR_NAME = 'name' +ATTR_PERCENT_CHANGE_24H = 'percent_change_24h' +ATTR_PERCENT_CHANGE_7D = 'percent_change_7d' +ATTR_PRICE = 'price_usd' +ATTR_SYMBOL = 'symbol' +ATTR_TOTAL_SUPPLY = 'total_supply' + +CONF_CURRENCY = 'currency' + +DEFAULT_CURRENCY = 'bitcoin' + +ICON = 'mdi:currency-usd' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_CURRENCY, default=DEFAULT_CURRENCY): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the CoinMarketCap sensor.""" + currency = config.get(CONF_CURRENCY) + + try: + CoinMarketCapData(currency).update() + except HTTPError: + _LOGGER.warning('Currency "%s" is not available. Using "bitcoin"', + currency) + currency = DEFAULT_CURRENCY + + add_devices([CoinMarketCapSensor(CoinMarketCapData(currency))]) + + +# pylint: disable=too-few-public-methods +class CoinMarketCapSensor(Entity): + """Representation of a CoinMarketCap sensor.""" + + def __init__(self, data): + """Initialize the sensor.""" + self.data = data + self._ticker = None + self._unit_of_measurement = 'USD' + self.update() + + @property + def name(self): + """Return the name of the sensor.""" + return self._ticker.get('name') + + @property + def state(self): + """Return the state of the sensor.""" + return self._ticker.get('price_usd') + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + @property + def state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_24H_VOLUME_USD: self._ticker.get('24h_volume_usd'), + ATTR_AVAILABLE_SUPPLY: self._ticker.get('available_supply'), + ATTR_MARKET_CAP: self._ticker.get('market_cap_usd'), + ATTR_PERCENT_CHANGE_24H: self._ticker.get('percent_change_24h'), + ATTR_PERCENT_CHANGE_7D: self._ticker.get('percent_change_7d'), + ATTR_SYMBOL: self._ticker.get('symbol'), + ATTR_TOTAL_SUPPLY: self._ticker.get('total_supply'), + } + + # pylint: disable=too-many-branches + def update(self): + """Get the latest data and updates the states.""" + self.data.update() + self._ticker = json.loads( + self.data.ticker.decode('utf-8').strip('\n '))[0] + + +class CoinMarketCapData(object): + """Get the latest data and update the states.""" + + def __init__(self, currency): + """Initialize the data object.""" + self.currency = currency + self.ticker = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from blockchain.info.""" + from coinmarketcap import Market + + self.ticker = Market().ticker(self.currency) diff --git a/homeassistant/components/sensor/command_line.py b/homeassistant/components/sensor/command_line.py index eb1fb4603e2..f26d2680a26 100644 --- a/homeassistant/components/sensor/command_line.py +++ b/homeassistant/components/sensor/command_line.py @@ -8,35 +8,41 @@ import logging import subprocess from datetime import timedelta -from homeassistant.const import CONF_VALUE_TEMPLATE +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, CONF_VALUE_TEMPLATE, CONF_UNIT_OF_MEASUREMENT, CONF_COMMAND) from homeassistant.helpers.entity import Entity from homeassistant.helpers import template from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "Command Sensor" +DEFAULT_NAME = 'Command Sensor' -# Return cached results if last scan was less then this time ago MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, +}) + # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Command Sensor.""" - if config.get('command') is None: - _LOGGER.error('Missing required variable: "command"') - return False + name = config.get(CONF_NAME) + command = config.get(CONF_COMMAND) + unit = config.get(CONF_UNIT_OF_MEASUREMENT) + value_template = config.get(CONF_VALUE_TEMPLATE) - data = CommandSensorData(config.get('command')) + data = CommandSensorData(command) - add_devices_callback([CommandSensor( - hass, - data, - config.get('name', DEFAULT_NAME), - config.get('unit_of_measurement'), - config.get(CONF_VALUE_TEMPLATE) - )]) + add_devices([CommandSensor(hass, data, name, unit, value_template)]) # pylint: disable=too-many-arguments diff --git a/homeassistant/components/sensor/dht.py b/homeassistant/components/sensor/dht.py index 109e539c599..461c2fb1eeb 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -7,7 +7,12 @@ https://home-assistant.io/components/sensor.dht/ import logging from datetime import timedelta -from homeassistant.const import TEMP_FAHRENHEIT +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + TEMP_FAHRENHEIT, CONF_NAME, CONF_MONITORED_CONDITIONS) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle from homeassistant.util.temperature import celsius_to_fahrenheit @@ -18,18 +23,29 @@ REQUIREMENTS = ['http://github.com/adafruit/Adafruit_Python_DHT/archive/' '#Adafruit_DHT==1.3.0'] _LOGGER = logging.getLogger(__name__) + CONF_PIN = 'pin' CONF_SENSOR = 'sensor' + +DEFAULT_NAME = 'DHT Sensor' + +# DHT11 is able to deliver data once per second, DHT22 once every two +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + SENSOR_TEMPERATURE = 'temperature' SENSOR_HUMIDITY = 'humidity' SENSOR_TYPES = { SENSOR_TEMPERATURE: ['Temperature', None], SENSOR_HUMIDITY: ['Humidity', '%'] } -DEFAULT_NAME = "DHT Sensor" -# Return cached results if last scan was less then this time ago -# DHT11 is able to deliver data once per second, DHT22 once every two -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SENSOR): cv.string, + vol.Required(CONF_PIN): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) def setup_platform(hass, config, add_devices, discovery_info=None): @@ -46,23 +62,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None): sensor = available_sensors.get(config.get(CONF_SENSOR)) pin = config.get(CONF_PIN) - if not sensor or not pin: - _LOGGER.error( - "Config error " - "Please check your settings for DHT, sensor not supported.") - return None + if not sensor: + _LOGGER.error("DHT sensor type is not supported") + return False data = DHTClient(Adafruit_DHT, sensor, pin) dev = [] - name = config.get('name', DEFAULT_NAME) + name = config.get(CONF_NAME) try: - for variable in config['monitored_conditions']: - if variable not in SENSOR_TYPES: - _LOGGER.error('Sensor type: "%s" does not exist', variable) - else: - dev.append( - DHTSensor(data, variable, SENSOR_TYPES[variable][1], name)) + for variable in config[CONF_MONITORED_CONDITIONS]: + dev.append(DHTSensor( + data, variable, SENSOR_TYPES[variable][1], name)) except KeyError: pass @@ -109,8 +120,7 @@ class DHTSensor(Entity): if (temperature >= -20) and (temperature < 80): self._state = temperature if self.temp_unit == TEMP_FAHRENHEIT: - self._state = round(celsius_to_fahrenheit(temperature), - 1) + self._state = round(celsius_to_fahrenheit(temperature), 1) elif self.type == SENSOR_HUMIDITY: humidity = round(data[SENSOR_HUMIDITY], 1) if (humidity >= 0) and (humidity <= 100): diff --git a/homeassistant/components/sensor/eliqonline.py b/homeassistant/components/sensor/eliqonline.py index 0a82f5d587c..421940b9c7d 100644 --- a/homeassistant/components/sensor/eliqonline.py +++ b/homeassistant/components/sensor/eliqonline.py @@ -7,18 +7,33 @@ https://home-assistant.io/components/sensor.eliqonline/ import logging from urllib.error import URLError -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, STATE_UNKNOWN +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_ACCESS_TOKEN, CONF_NAME, STATE_UNKNOWN) from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['eliqonline==1.0.12'] _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['eliqonline==1.0.12'] -DEFAULT_NAME = "ELIQ Online" -UNIT_OF_MEASUREMENT = "W" -ICON = "mdi:speedometer" -CONF_CHANNEL_ID = "channel_id" +CONF_CHANNEL_ID = 'channel_id' + +DEFAULT_NAME = 'ELIQ Online' + +ICON = 'mdi:speedometer' + SCAN_INTERVAL = 60 +UNIT_OF_MEASUREMENT = 'W' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Optional(CONF_CHANNEL_ID): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the ELIQ Online sensor.""" @@ -28,13 +43,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME, DEFAULT_NAME) channel_id = config.get(CONF_CHANNEL_ID) - if access_token is None: - _LOGGER.error( - "Configuration Error: " - "Please make sure you have configured your access token " - "that can be aquired from https://my.eliq.se/user/settings/api") - return False - api = eliqonline.API(access_token) try: diff --git a/homeassistant/components/sensor/fastdotcom.py b/homeassistant/components/sensor/fastdotcom.py index 95d91d42efc..ad6aa2ca630 100644 --- a/homeassistant/components/sensor/fastdotcom.py +++ b/homeassistant/components/sensor/fastdotcom.py @@ -14,8 +14,7 @@ from homeassistant.components.sensor import (DOMAIN, PLATFORM_SCHEMA) from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_change -REQUIREMENTS = ['https://github.com/nkgilley/fast.com/archive/' - 'master.zip#fastdotcom==0.0.1'] +REQUIREMENTS = ['fastdotcom==0.0.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index eb87527a546..b99a4f320c9 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -16,7 +16,7 @@ from homeassistant.loader import get_component from homeassistant.components.http import HomeAssistantView _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ["fitbit==0.2.2"] +REQUIREMENTS = ["fitbit==0.2.3"] DEPENDENCIES = ["http"] ICON = "mdi:walk" diff --git a/homeassistant/components/sensor/forecast.py b/homeassistant/components/sensor/forecast.py index 4f3b2cd17c7..213760fee0d 100644 --- a/homeassistant/components/sensor/forecast.py +++ b/homeassistant/components/sensor/forecast.py @@ -6,20 +6,28 @@ https://home-assistant.io/components/sensor.forecast/ """ import logging from datetime import timedelta + import voluptuous as vol from requests.exceptions import ConnectionError as ConnectError, \ HTTPError, Timeout -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_API_KEY, CONF_NAME, - CONF_MONITORED_CONDITIONS) +from homeassistant.const import ( + CONF_API_KEY, CONF_NAME, CONF_MONITORED_CONDITIONS) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['python-forecastio==1.3.4'] + _LOGGER = logging.getLogger(__name__) +CONF_UNITS = 'units' + +DEFAULT_NAME = 'Forecast.io' + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) + # Sensor types are defined like so: # Name, si unit, us unit, ca unit, uk unit, uk2 unit SENSOR_TYPES = { @@ -57,22 +65,16 @@ SENSOR_TYPES = { 'precip_intensity_max': ['Daily Max Precip Intensity', 'mm', 'in', 'mm', 'mm', 'mm'], } -DEFAULT_NAME = "Forecast.io" -CONF_UNITS = 'units' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]), + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNITS): vol.In(['auto', 'si', 'us', 'ca', 'uk', 'uk2']) }) -# Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) - - def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Forecast.io sensor.""" # Validate the configuration @@ -100,7 +102,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) - # Initialize and add all of the sensors. sensors = [] for variable in config[CONF_MONITORED_CONDITIONS]: sensors.append(ForeCastSensor(forecast_data, variable, name)) @@ -249,10 +250,8 @@ class ForeCastData(object): import forecastio try: - self.data = forecastio.load_forecast(self._api_key, - self.latitude, - self.longitude, - units=self.units) + self.data = forecastio.load_forecast( + self._api_key, self.latitude, self.longitude, units=self.units) except (ConnectError, HTTPError, Timeout, ValueError) as error: raise ValueError("Unable to init Forecast.io. - %s", error) self.unit_system = self.data.json['flags']['units'] diff --git a/homeassistant/components/sensor/fritzbox_callmonitor.py b/homeassistant/components/sensor/fritzbox_callmonitor.py index 7525e5fcc81..82f6ae839fb 100644 --- a/homeassistant/components/sensor/fritzbox_callmonitor.py +++ b/homeassistant/components/sensor/fritzbox_callmonitor.py @@ -1,35 +1,49 @@ """ A sensor to monitor incoming and outgoing phone calls on a Fritz!Box router. -To activate the call monitor on your Fritz!Box, dial #96*5* from any phone -connected to it. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.fritzbox_callmonitor/ """ import logging import socket import threading import datetime import time + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_HOST, CONF_PORT, CONF_NAME) from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Phone' DEFAULT_HOST = '169.254.1.1' # IP valid for all Fritz!Box routers DEFAULT_PORT = 1012 -# sensor values -VALUE_DEFAULT = 'idle' # initial value + +VALUE_DEFAULT = 'idle' VALUE_RING = 'ringing' VALUE_CALL = 'dialing' VALUE_CONNECT = 'talking' VALUE_DISCONNECT = 'idle' + INTERVAL_RECONNECT = 60 +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup Fritz!Box call monitor sensor platform.""" - host = config.get('host', DEFAULT_HOST) - port = config.get('port', DEFAULT_PORT) + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) - sensor = FritzBoxCallSensor(name=config.get('name', DEFAULT_NAME)) + sensor = FritzBoxCallSensor(name=name) add_devices([sensor]) diff --git a/homeassistant/components/sensor/gpsd.py b/homeassistant/components/sensor/gpsd.py index a9f8245b738..0fb24c96283 100644 --- a/homeassistant/components/sensor/gpsd.py +++ b/homeassistant/components/sensor/gpsd.py @@ -15,7 +15,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['gps3==0.33.2'] +REQUIREMENTS = ['gps3==0.33.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/gtfs.py b/homeassistant/components/sensor/gtfs.py index ad954899e6d..a9c6f36bf54 100644 --- a/homeassistant/components/sensor/gtfs.py +++ b/homeassistant/components/sensor/gtfs.py @@ -9,29 +9,47 @@ import logging import datetime import threading -from homeassistant.helpers.entity import Entity +import voluptuous as vol -_LOGGER = logging.getLogger(__name__) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ["https://github.com/robbiet480/pygtfs/archive/" "00546724e4bbcb3053110d844ca44e2246267dd8.zip#" "pygtfs==0.1.3"] -ICON = "mdi:train" +_LOGGER = logging.getLogger(__name__) + +CONF_DATA = 'data' +CONF_DESTINATION = 'destination' +CONF_ORIGIN = 'origin' + +DEFAULT_NAME = 'GTFS Sensor' +DEFAULT_PATH = 'gtfs' + +ICON = 'mdi:train' + +TIME_FORMAT = '%Y-%m-%d %H:%M:%S' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ORIGIN): cv.string, + vol.Required(CONF_DESTINATION): cv.string, + vol.Required(CONF_DATA): cv.isfile, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) -TIME_FORMAT = "%Y-%m-%d %H:%M:%S" # pylint: disable=too-many-locals - - def get_next_departure(sched, start_station_id, end_station_id): """Get the next departure for the given sched.""" origin_station = sched.stops_by_id(start_station_id)[0] destination_station = sched.stops_by_id(end_station_id)[0] now = datetime.datetime.now() - day_name = now.strftime("%A").lower() - now_str = now.strftime("%H:%M:%S") + day_name = now.strftime('%A').lower() + now_str = now.strftime('%H:%M:%S') from sqlalchemy.sql import text @@ -78,9 +96,9 @@ def get_next_departure(sched, start_station_id, end_station_id): for row in result: item = row - today = datetime.datetime.today().strftime("%Y-%m-%d") - departure_time_string = "{} {}".format(today, item[2]) - arrival_time_string = "{} {}".format(today, item[3]) + today = datetime.datetime.today().strftime('%Y-%m-%d') + departure_time_string = '{} {}'.format(today, item[2]) + arrival_time_string = '{} {}'.format(today, item[3]) departure_time = datetime.datetime.strptime(departure_time_string, TIME_FORMAT) arrival_time = datetime.datetime.strptime(arrival_time_string, @@ -91,72 +109,61 @@ def get_next_departure(sched, start_station_id, end_station_id): route = sched.routes_by_id(item[1])[0] - origin_stoptime_arrival_time = "{} {}".format(today, item[4]) - - origin_stoptime_departure_time = "{} {}".format(today, item[5]) - - dest_stoptime_arrival_time = "{} {}".format(today, item[11]) - - dest_stoptime_depart_time = "{} {}".format(today, item[12]) + origin_stoptime_arrival_time = '{} {}'.format(today, item[4]) + origin_stoptime_departure_time = '{} {}'.format(today, item[5]) + dest_stoptime_arrival_time = '{} {}'.format(today, item[11]) + dest_stoptime_depart_time = '{} {}'.format(today, item[12]) origin_stop_time_dict = { - "Arrival Time": origin_stoptime_arrival_time, - "Departure Time": origin_stoptime_departure_time, - "Drop Off Type": item[6], "Pickup Type": item[7], - "Shape Dist Traveled": item[8], "Headsign": item[9], - "Sequence": item[10] + 'Arrival Time': origin_stoptime_arrival_time, + 'Departure Time': origin_stoptime_departure_time, + 'Drop Off Type': item[6], 'Pickup Type': item[7], + 'Shape Dist Traveled': item[8], 'Headsign': item[9], + 'Sequence': item[10] } destination_stop_time_dict = { - "Arrival Time": dest_stoptime_arrival_time, - "Departure Time": dest_stoptime_depart_time, - "Drop Off Type": item[13], "Pickup Type": item[14], - "Shape Dist Traveled": item[15], "Headsign": item[16], - "Sequence": item[17] + 'Arrival Time': dest_stoptime_arrival_time, + 'Departure Time': dest_stoptime_depart_time, + 'Drop Off Type': item[13], 'Pickup Type': item[14], + 'Shape Dist Traveled': item[15], 'Headsign': item[16], + 'Sequence': item[17] } return { - "trip_id": item[0], - "trip": sched.trips_by_id(item[0])[0], - "route": route, - "agency": sched.agencies_by_id(route.agency_id)[0], - "origin_station": origin_station, - "departure_time": departure_time, - "destination_station": destination_station, - "arrival_time": arrival_time, - "seconds_until_departure": seconds_until, - "minutes_until_departure": minutes_until, - "origin_stop_time": origin_stop_time_dict, - "destination_stop_time": destination_stop_time_dict + 'trip_id': item[0], + 'trip': sched.trips_by_id(item[0])[0], + 'route': route, + 'agency': sched.agencies_by_id(route.agency_id)[0], + 'origin_station': origin_station, + 'departure_time': departure_time, + 'destination_station': destination_station, + 'arrival_time': arrival_time, + 'seconds_until_departure': seconds_until, + 'minutes_until_departure': minutes_until, + 'origin_stop_time': origin_stop_time_dict, + 'destination_stop_time': destination_stop_time_dict } def setup_platform(hass, config, add_devices, discovery_info=None): """Get the GTFS sensor.""" - if config.get("origin") is None: - _LOGGER.error("Origin must be set in the GTFS configuration!") - return False - - if config.get("destination") is None: - _LOGGER.error("Destination must be set in the GTFS configuration!") - return False - - if config.get("data") is None: - _LOGGER.error("Data must be set in the GTFS configuration!") - return False - - gtfs_dir = hass.config.path("gtfs") + gtfs_dir = hass.config.path(DEFAULT_PATH) + data = config.get(CONF_DATA) + origin = config.get(CONF_ORIGIN) + destination = config.get(CONF_DESTINATION) + name = config.get(CONF_NAME) if not os.path.exists(gtfs_dir): os.makedirs(gtfs_dir) - if not os.path.exists(os.path.join(gtfs_dir, config["data"])): + if not os.path.exists(os.path.join(gtfs_dir, data)): _LOGGER.error("The given GTFS data file/folder was not found!") return False import pygtfs - split_file_name = os.path.splitext(config["data"]) + split_file_name = os.path.splitext(data) sqlite_file = "{}.sqlite".format(split_file_name[0]) joined_path = os.path.join(gtfs_dir, sqlite_file) @@ -164,27 +171,22 @@ def setup_platform(hass, config, add_devices, discovery_info=None): # pylint: disable=no-member if len(gtfs.feeds) < 1: - pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, - config["data"])) + pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, data)) + + add_devices([GTFSDepartureSensor(gtfs, name, origin, destination)]) - dev = [] - dev.append(GTFSDepartureSensor(gtfs, config["origin"], - config["destination"])) - add_devices(dev) # pylint: disable=too-many-instance-attributes,too-few-public-methods - - class GTFSDepartureSensor(Entity): """Implementation of an GTFS departures sensor.""" - def __init__(self, pygtfs, origin, destination): + def __init__(self, pygtfs, name, origin, destination): """Initialize the sensor.""" self._pygtfs = pygtfs self.origin = origin self.destination = destination - self._name = "GTFS Sensor" - self._unit_of_measurement = "min" + self._name = name + self._unit_of_measurement = 'min' self._state = 0 self._attributes = {} self.lock = threading.Lock() @@ -220,23 +222,22 @@ class GTFSDepartureSensor(Entity): with self.lock: self._departure = get_next_departure(self._pygtfs, self.origin, self.destination) - self._state = self._departure["minutes_until_departure"] + self._state = self._departure['minutes_until_departure'] - origin_station = self._departure["origin_station"] - destination_station = self._departure["destination_station"] - origin_stop_time = self._departure["origin_stop_time"] - destination_stop_time = self._departure["destination_stop_time"] - agency = self._departure["agency"] - route = self._departure["route"] - trip = self._departure["trip"] + origin_station = self._departure['origin_station'] + destination_station = self._departure['destination_station'] + origin_stop_time = self._departure['origin_stop_time'] + destination_stop_time = self._departure['destination_stop_time'] + agency = self._departure['agency'] + route = self._departure['route'] + trip = self._departure['trip'] - name = "{} {} to {} next departure" + name = '{} {} to {} next departure' self._name = name.format(agency.agency_name, origin_station.stop_id, destination_station.stop_id) # Build attributes - self._attributes = {} def dict_for_table(resource): @@ -247,22 +248,22 @@ class GTFSDepartureSensor(Entity): def append_keys(resource, prefix=None): """Properly format key val pairs to append to attributes.""" for key, val in resource.items(): - if val == "" or val is None or key == "feed_id": + if val == "" or val is None or key == 'feed_id': continue - pretty_key = key.replace("_", " ") + pretty_key = key.replace('_', ' ') pretty_key = pretty_key.title() - pretty_key = pretty_key.replace("Id", "ID") - pretty_key = pretty_key.replace("Url", "URL") + pretty_key = pretty_key.replace('Id', 'ID') + pretty_key = pretty_key.replace('Url', 'URL') if prefix is not None and \ pretty_key.startswith(prefix) is False: - pretty_key = "{} {}".format(prefix, pretty_key) + pretty_key = '{} {}'.format(prefix, pretty_key) self._attributes[pretty_key] = val - append_keys(dict_for_table(agency), "Agency") - append_keys(dict_for_table(route), "Route") - append_keys(dict_for_table(trip), "Trip") - append_keys(dict_for_table(origin_station), "Origin Station") + append_keys(dict_for_table(agency), 'Agency') + append_keys(dict_for_table(route), 'Route') + append_keys(dict_for_table(trip), 'Trip') + append_keys(dict_for_table(origin_station), 'Origin Station') append_keys(dict_for_table(destination_station), - "Destination Station") - append_keys(origin_stop_time, "Origin Stop") - append_keys(destination_stop_time, "Destination Stop") + 'Destination Station') + append_keys(origin_stop_time, 'Origin Stop') + append_keys(destination_stop_time, 'Destination Stop') diff --git a/homeassistant/components/sensor/homematic.py b/homeassistant/components/sensor/homematic.py index 35cc4aea42b..8857ee6d889 100644 --- a/homeassistant/components/sensor/homematic.py +++ b/homeassistant/components/sensor/homematic.py @@ -46,9 +46,11 @@ def setup_platform(hass, config, add_callback_devices, discovery_info=None): if discovery_info is None: return - return homematic.setup_hmdevice_discovery_helper(HMSensor, - discovery_info, - add_callback_devices) + return homematic.setup_hmdevice_discovery_helper( + HMSensor, + discovery_info, + add_callback_devices + ) class HMSensor(homematic.HMDevice): @@ -76,45 +78,8 @@ class HMSensor(homematic.HMDevice): return HM_UNIT_HA_CAST.get(self._state, None) - def _check_hm_to_ha_object(self): - """Check if possible to use the HM Object as this HA type.""" - from pyhomematic.devicetypes.sensors import HMSensor as pyHMSensor - - # Check compatibility from HMDevice - if not super()._check_hm_to_ha_object(): - return False - - # Check if the homematic device is correct for this HA device - if not isinstance(self._hmdevice, pyHMSensor): - _LOGGER.critical("This %s can't be use as sensor!", self._name) - return False - - # Does user defined value exist? - if self._state and self._state not in self._hmdevice.SENSORNODE: - # pylint: disable=logging-too-many-args - _LOGGER.critical("This %s have no sensor with %s! Values are", - self._name, self._state, - str(self._hmdevice.SENSORNODE.keys())) - return False - - # No param is set and more than 1 sensor nodes are present - if self._state is None and len(self._hmdevice.SENSORNODE) > 1: - _LOGGER.critical("This %s has multiple sensor nodes. " + - "Please us param. Values are: %s", self._name, - str(self._hmdevice.SENSORNODE.keys())) - return False - - _LOGGER.debug("%s is okay for linking", self._name) - return True - def _init_data_struct(self): """Generate a data dict (self._data) from hm metadata.""" - super()._init_data_struct() - - if self._state is None and len(self._hmdevice.SENSORNODE) == 1: - for value in self._hmdevice.SENSORNODE: - self._state = value - # Add state to data dict if self._state: _LOGGER.debug("%s init datadict with main node '%s'", self._name, diff --git a/homeassistant/components/sensor/lastfm.py b/homeassistant/components/sensor/lastfm.py index 3001171081e..2e493399d5b 100644 --- a/homeassistant/components/sensor/lastfm.py +++ b/homeassistant/components/sensor/lastfm.py @@ -5,12 +5,25 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.lastfm/ """ import re + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.const import CONF_API_KEY +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pylast==1.6.0'] + +CONF_USERS = 'users' ICON = 'mdi:lastfm' -REQUIREMENTS = ['pylast==1.6.0'] +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_USERS, default=[]): + vol.All(cv.ensure_list, [cv.string]), +}) # pylint: disable=unused-argument @@ -18,9 +31,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Last.fm platform.""" import pylast as lastfm network = lastfm.LastFMNetwork(api_key=config.get(CONF_API_KEY)) + add_devices( [LastfmSensor(username, - network) for username in config.get("users", [])]) + network) for username in config.get(CONF_USERS)]) class LastfmSensor(Entity): diff --git a/homeassistant/components/sensor/linux_battery.py b/homeassistant/components/sensor/linux_battery.py new file mode 100644 index 00000000000..c1d145953e3 --- /dev/null +++ b/homeassistant/components/sensor/linux_battery.py @@ -0,0 +1,125 @@ +""" +Details about the built-in battery. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.linux_battery/ +""" +import logging +import os + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['batinfo==0.3'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_NAME = 'name' +ATTR_PATH = 'path' +ATTR_ALARM = 'alarm' +ATTR_CAPACITY = 'capacity' +ATTR_CAPACITY_LEVEL = 'capacity_level' +ATTR_CYCLE_COUNT = 'cycle_count' +ATTR_ENERGY_FULL = 'energy_full' +ATTR_ENERGY_FULL_DESIGN = 'energy_full_design' +ATTR_ENERGY_NOW = 'energy_now' +ATTR_MANUFACTURER = 'manufacturer' +ATTR_MODEL_NAME = 'model_name' +ATTR_POWER_NOW = 'power_now' +ATTR_SERIAL_NUMBER = 'serial_number' +ATTR_STATUS = 'status' +ATTR_VOLTAGE_MIN_DESIGN = 'voltage_min_design' +ATTR_VOLTAGE_NOW = 'voltage_now' + +CONF_BATTERY = 'battery' + +DEFAULT_BATTERY = 1 +DEFAULT_NAME = 'Battery' +DEFAULT_PATH = '/sys/class/power_supply' + +ICON = 'mdi:battery' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_BATTERY, default=DEFAULT_BATTERY): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Linux Battery sensor.""" + name = config.get(CONF_NAME) + battery_id = config.get(CONF_BATTERY) + + try: + os.listdir(os.path.join(DEFAULT_PATH, 'BAT{}'.format(battery_id))) + except FileNotFoundError: + _LOGGER.error("No battery found") + return False + + add_devices([LinuxBatterySensor(name, battery_id)]) + + +# pylint: disable=too-few-public-methods +class LinuxBatterySensor(Entity): + """Representation of a Linux Battery sensor.""" + + def __init__(self, name, battery_id): + """Initialize the battery sensor.""" + import batinfo + self._battery = batinfo.Batteries() + + self._name = name + self._battery_stat = None + self._battery_id = battery_id - 1 + self._unit_of_measurement = '%' + self.update() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._battery_stat.capacity + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + @property + def state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_NAME: self._battery_stat.name, + ATTR_PATH: self._battery_stat.path, + ATTR_ALARM: self._battery_stat.alarm, + ATTR_CAPACITY_LEVEL: self._battery_stat.capacity_level, + ATTR_CYCLE_COUNT: self._battery_stat.cycle_count, + ATTR_ENERGY_FULL: self._battery_stat.energy_full, + ATTR_ENERGY_FULL_DESIGN: self._battery_stat.energy_full_design, + ATTR_ENERGY_NOW: self._battery_stat.energy_now, + ATTR_MANUFACTURER: self._battery_stat.manufacturer, + ATTR_MODEL_NAME: self._battery_stat.model_name, + ATTR_POWER_NOW: self._battery_stat.power_now, + ATTR_SERIAL_NUMBER: self._battery_stat.serial_number, + ATTR_STATUS: self._battery_stat.status, + ATTR_VOLTAGE_MIN_DESIGN: self._battery_stat.voltage_min_design, + ATTR_VOLTAGE_NOW: self._battery_stat.voltage_now, + } + + def update(self): + """Get the latest data and updates the states.""" + self._battery.update() + self._battery_stat = self._battery.stat[self._battery_id] diff --git a/homeassistant/components/sensor/mfi.py b/homeassistant/components/sensor/mfi.py index 90d07811304..0f06426a05b 100644 --- a/homeassistant/components/sensor/mfi.py +++ b/homeassistant/components/sensor/mfi.py @@ -7,23 +7,30 @@ https://home-assistant.io/components/sensor.mfi/ import logging import requests +import voluptuous as vol -from homeassistant.components.sensor import DOMAIN +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, STATE_ON, STATE_OFF, CONF_HOST) -from homeassistant.helpers import validate_config + CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, STATE_ON, STATE_OFF, CONF_HOST, + CONF_SSL, CONF_VERIFY_SSL, CONF_PORT) from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['mficlient==0.3.0'] _LOGGER = logging.getLogger(__name__) +DEFAULT_PORT = 6443 +DEFAULT_SSL = True +DEFAULT_VERIFY_SSL = True + DIGITS = { 'volts': 1, 'amps': 1, 'active_power': 0, 'temperature': 1, } + SENSOR_MODELS = [ 'Ubiquiti mFi-THS', 'Ubiquiti mFi-CS', @@ -31,28 +38,27 @@ SENSOR_MODELS = [ 'Input Analog', 'Input Digital', ] -CONF_TLS = 'use_tls' -CONF_VERIFY_TLS = 'verify_tls' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, +}) # pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Setup mFi sensors.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_HOST, - CONF_USERNAME, - CONF_PASSWORD]}, - _LOGGER): - _LOGGER.error('A host, username, and password are required') - return False - host = config.get(CONF_HOST) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - use_tls = bool(config.get(CONF_TLS, True)) - verify_tls = bool(config.get(CONF_VERIFY_TLS, True)) - default_port = use_tls and 6443 or 6080 - port = int(config.get('port', default_port)) + use_tls = config.get(CONF_SSL) + verify_tls = config.get(CONF_VERIFY_SSL) + default_port = use_tls and DEFAULT_PORT or 6080 + port = int(config.get(CONF_PORT, default_port)) from mficlient.client import FailedToLogin, MFiClient @@ -85,7 +91,13 @@ class MfiSensor(Entity): @property def state(self): """Return the state of the sensor.""" - if self._port.model == 'Input Digital': + try: + tag = self._port.tag + except ValueError: + tag = None + if tag is None: + return STATE_OFF + elif self._port.model == 'Input Digital': return self._port.value > 0 and STATE_ON or STATE_OFF else: digits = DIGITS.get(self._port.tag, 0) @@ -94,13 +106,18 @@ class MfiSensor(Entity): @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - if self._port.tag == 'temperature': + try: + tag = self._port.tag + except ValueError: + return 'State' + + if tag == 'temperature': return TEMP_CELSIUS - elif self._port.tag == 'active_pwr': + elif tag == 'active_pwr': return 'Watts' elif self._port.model == 'Input Digital': return 'State' - return self._port.tag + return tag def update(self): """Get the latest data.""" diff --git a/homeassistant/components/sensor/modbus.py b/homeassistant/components/sensor/modbus.py index d6c85993162..063c1dc8600 100644 --- a/homeassistant/components/sensor/modbus.py +++ b/homeassistant/components/sensor/modbus.py @@ -114,12 +114,11 @@ class ModbusSensor(Entity): def update(self): """Update the state of the sensor.""" if self._coil: - result = modbus.NETWORK.read_coils(self.register, 1) + result = modbus.HUB.read_coils(self.slave, self.register, 1) self._value = result.bits[0] else: - result = modbus.NETWORK.read_holding_registers( - unit=self.slave, address=self.register, - count=1) + result = modbus.HUB.read_holding_registers( + self.slave, self.register, 1) val = 0 for i, res in enumerate(result.registers): val += res * (2**(i*16)) diff --git a/homeassistant/components/sensor/mold_indicator.py b/homeassistant/components/sensor/mold_indicator.py index 4e59cd2cd62..b8f635ec593 100644 --- a/homeassistant/components/sensor/mold_indicator.py +++ b/homeassistant/components/sensor/mold_indicator.py @@ -7,48 +7,51 @@ https://home-assistant.io/components/sensor.mold_indicator/ import logging import math +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA import homeassistant.util as util from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_state_change -from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, - TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_NAME) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "Mold Indicator" -CONF_INDOOR_TEMP = "indoor_temp_sensor" -CONF_OUTDOOR_TEMP = "outdoor_temp_sensor" -CONF_INDOOR_HUMIDITY = "indoor_humidity_sensor" -CONF_CALIBRATION_FACTOR = "calibration_factor" +DEFAULT_NAME = 'Mold Indicator' +CONF_INDOOR_TEMP = 'indoor_temp_sensor' +CONF_OUTDOOR_TEMP = 'outdoor_temp_sensor' +CONF_INDOOR_HUMIDITY = 'indoor_humidity_sensor' +CONF_CALIBRATION_FACTOR = 'calibration_factor' MAGNUS_K2 = 17.62 MAGNUS_K3 = 243.12 -ATTR_DEWPOINT = "Dewpoint" -ATTR_CRITICAL_TEMP = "Est. Crit. Temp" +ATTR_DEWPOINT = 'Dewpoint' +ATTR_CRITICAL_TEMP = 'Est. Crit. Temp' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_INDOOR_TEMP): cv.entity_id, + vol.Required(CONF_OUTDOOR_TEMP): cv.entity_id, + vol.Required(CONF_INDOOR_HUMIDITY): cv.entity_id, + vol.Optional(CONF_CALIBRATION_FACTOR): vol.Coerce(float), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup MoldIndicator sensor.""" - name = config.get('name', DEFAULT_NAME) + name = config.get(CONF_NAME, DEFAULT_NAME) indoor_temp_sensor = config.get(CONF_INDOOR_TEMP) outdoor_temp_sensor = config.get(CONF_OUTDOOR_TEMP) indoor_humidity_sensor = config.get(CONF_INDOOR_HUMIDITY) - calib_factor = util.convert(config.get(CONF_CALIBRATION_FACTOR), - float, None) + calib_factor = config.get(CONF_CALIBRATION_FACTOR) - if None in (indoor_temp_sensor, - outdoor_temp_sensor, indoor_humidity_sensor): - _LOGGER.error('Missing required key %s, %s or %s', - CONF_INDOOR_TEMP, CONF_OUTDOOR_TEMP, - CONF_INDOOR_HUMIDITY) - return False - - add_devices_callback([MoldIndicator( - hass, name, indoor_temp_sensor, - outdoor_temp_sensor, indoor_humidity_sensor, - calib_factor)]) + add_devices([MoldIndicator( + hass, name, indoor_temp_sensor, outdoor_temp_sensor, + indoor_humidity_sensor, calib_factor)]) # pylint: disable=too-many-instance-attributes @@ -83,16 +86,14 @@ class MoldIndicator(Entity): indoor_hum = hass.states.get(indoor_humidity_sensor) if indoor_temp: - self._indoor_temp = \ - MoldIndicator._update_temp_sensor(indoor_temp) + self._indoor_temp = MoldIndicator._update_temp_sensor(indoor_temp) if outdoor_temp: - self._outdoor_temp = \ - MoldIndicator._update_temp_sensor(outdoor_temp) + self._outdoor_temp = MoldIndicator._update_temp_sensor( + outdoor_temp) if indoor_hum: - self._indoor_hum = \ - MoldIndicator._update_hum_sensor(indoor_hum) + self._indoor_hum = MoldIndicator._update_hum_sensor(indoor_hum) self.update() @@ -130,19 +131,13 @@ class MoldIndicator(Entity): state.state) return None - # check unit - if unit != "%": - _LOGGER.error( - "Humidity sensor has unsupported unit: %s %s", - unit, - " (allowed: %)") + if unit != '%': + _LOGGER.error("Humidity sensor has unsupported unit: %s %s", + unit, " (allowed: %)") - # check range if hum > 100 or hum < 0: - _LOGGER.error( - "Humidity sensor out of range: %s %s", - hum, - " (allowed: 0-100%)") + _LOGGER.error("Humidity sensor out of range: %s %s", hum, + " (allowed: 0-100%)") return hum @@ -162,15 +157,10 @@ class MoldIndicator(Entity): return if entity_id == self._indoor_temp_sensor: - # update the indoor temp sensor self._indoor_temp = MoldIndicator._update_temp_sensor(new_state) - elif entity_id == self._outdoor_temp_sensor: - # update outdoor temp sensor self._outdoor_temp = MoldIndicator._update_temp_sensor(new_state) - elif entity_id == self._indoor_humidity_sensor: - # update humidity self._indoor_hum = MoldIndicator._update_hum_sensor(new_state) self.update() @@ -206,9 +196,8 @@ class MoldIndicator(Entity): self._outdoor_temp + (self._indoor_temp - self._outdoor_temp) / \ self._calib_factor - _LOGGER.debug( - "Estimated Critical Temperature: %f " + - TEMP_CELSIUS, self._crit_temp) + _LOGGER.debug("Estimated Critical Temperature: %f " + + TEMP_CELSIUS, self._crit_temp) # Then calculate the humidity at this point alpha = MAGNUS_K2 * self._crit_temp / (MAGNUS_K3 + self._crit_temp) @@ -242,7 +231,7 @@ class MoldIndicator(Entity): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return "%" + return '%' @property def state(self): @@ -260,9 +249,7 @@ class MoldIndicator(Entity): else: return { ATTR_DEWPOINT: - util.temperature.celsius_to_fahrenheit( - self._dewpoint), + util.temperature.celsius_to_fahrenheit(self._dewpoint), ATTR_CRITICAL_TEMP: - util.temperature.celsius_to_fahrenheit( - self._crit_temp), + util.temperature.celsius_to_fahrenheit(self._crit_temp), } diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index c3d4910b527..f12df688385 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -8,20 +8,19 @@ import logging import voluptuous as vol -import homeassistant.components.mqtt as mqtt +from homeassistant.components.mqtt import CONF_STATE_TOPIC, CONF_QOS from homeassistant.const import ( CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT) -from homeassistant.components.mqtt import CONF_STATE_TOPIC, CONF_QOS -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers import template +from homeassistant.helpers.entity import Entity +import homeassistant.components.mqtt as mqtt +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +DEFAULT_NAME = 'MQTT Sensor' DEPENDENCIES = ['mqtt'] -DEFAULT_NAME = "MQTT Sensor" - PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, @@ -33,9 +32,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup MQTT Sensor.""" add_devices([MqttSensor( hass, - config[CONF_NAME], - config[CONF_STATE_TOPIC], - config[CONF_QOS], + config.get(CONF_NAME), + config.get(CONF_STATE_TOPIC), + config.get(CONF_QOS), config.get(CONF_UNIT_OF_MEASUREMENT), config.get(CONF_VALUE_TEMPLATE), )]) diff --git a/homeassistant/components/sensor/mqtt_room.py b/homeassistant/components/sensor/mqtt_room.py index 6980b7e6f7b..a640d1e5268 100644 --- a/homeassistant/components/sensor/mqtt_room.py +++ b/homeassistant/components/sensor/mqtt_room.py @@ -11,7 +11,8 @@ import voluptuous as vol import homeassistant.components.mqtt as mqtt from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME, STATE_UNKNOWN +from homeassistant.const import ( + CONF_NAME, STATE_UNKNOWN, CONF_TIMEOUT) from homeassistant.components.mqtt import CONF_STATE_TOPIC import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -21,12 +22,18 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['mqtt'] -CONF_DEVICE_ID = 'device_id' -CONF_TIMEOUT = 'timeout' +ATTR_DEVICE_ID = 'device_id' +ATTR_DISTANCE = 'distance' +ATTR_ID = 'id' +ATTR_ROOM = 'room' + +CONF_DEVICE_ID = 'device_id' +CONF_ROOM = 'room' -DEFAULT_TOPIC = 'room_presence' -DEFAULT_TIMEOUT = 5 DEFAULT_NAME = 'Room Sensor' +DEFAULT_TIMEOUT = 5 +DEFAULT_TOPIC = 'room_presence' +DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_DEVICE_ID): cv.string, @@ -36,15 +43,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) MQTT_PAYLOAD = vol.Schema(vol.All(json.loads, vol.Schema({ - vol.Required('id'): cv.string, - vol.Required('distance'): vol.Coerce(float) + vol.Required(ATTR_ID): cv.string, + vol.Required(ATTR_DISTANCE): vol.Coerce(float), }, extra=vol.ALLOW_EXTRA))) # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup MQTT Sensor.""" - add_devices_callback([MQTTRoomSensor( + add_devices([MQTTRoomSensor( hass, config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), @@ -62,7 +69,7 @@ class MQTTRoomSensor(Entity): self._state = STATE_UNKNOWN self._hass = hass self._name = name - self._state_topic = state_topic + '/+' + self._state_topic = '{}{}'.format(state_topic, '/+') self._device_id = slugify(device_id).upper() self._timeout = timeout self._distance = None @@ -86,7 +93,7 @@ class MQTTRoomSensor(Entity): return device = _parse_update_data(topic, data) - if device.get('device_id') == self._device_id: + if device.get(CONF_DEVICE_ID) == self._device_id: if self._distance is None or self._updated is None: update_state(**device) else: @@ -95,8 +102,8 @@ class MQTTRoomSensor(Entity): # device is closer to another room OR # last update from other room was too long ago timediff = dt.utcnow() - self._updated - if device.get('room') == self._state \ - or device.get('distance') < self._distance \ + if device.get(ATTR_ROOM) == self._state \ + or device.get(ATTR_DISTANCE) < self._distance \ or timediff.seconds >= self._timeout: update_state(**device) @@ -116,7 +123,7 @@ class MQTTRoomSensor(Entity): def device_state_attributes(self): """Return the state attributes.""" return { - 'distance': self._distance + ATTR_DISTANCE: self._distance } @property @@ -129,11 +136,11 @@ def _parse_update_data(topic, data): """Parse the room presence update.""" parts = topic.split('/') room = parts[-1] - device_id = slugify(data.get('id')).upper() + device_id = slugify(data.get(ATTR_ID)).upper() distance = data.get('distance') parsed_data = { - 'device_id': device_id, - 'room': room, - 'distance': distance + ATTR_DEVICE_ID: device_id, + ATTR_ROOM: room, + ATTR_DISTANCE: distance } return parsed_data diff --git a/homeassistant/components/sensor/octoprint.py b/homeassistant/components/sensor/octoprint.py index f7e7fa30817..3b4635c829a 100644 --- a/homeassistant/components/sensor/octoprint.py +++ b/homeassistant/components/sensor/octoprint.py @@ -5,31 +5,44 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.octoprint/ """ import logging -import requests -from homeassistant.const import TEMP_CELSIUS, CONF_NAME +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + TEMP_CELSIUS, CONF_NAME, CONF_MONITORED_CONDITIONS) from homeassistant.helpers.entity import Entity from homeassistant.loader import get_component +import homeassistant.helpers.config_validation as cv -DEPENDENCIES = ["octoprint"] + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['octoprint'] + +DEFAULT_NAME = 'OctoPrint' SENSOR_TYPES = { # API Endpoint, Group, Key, unit - "Temperatures": ["printer", "temperature", "*", TEMP_CELSIUS], - "Current State": ["printer", "state", "text", None], - "Job Percentage": ["job", "progress", "completion", "%"], + 'Temperatures': ['printer', 'temperature', '*', TEMP_CELSIUS], + 'Current State': ['printer', 'state', 'text', None], + 'Job Percentage': ['job', 'progress', 'completion', '%'], } -_LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the available OctoPrint sensors.""" octoprint = get_component('octoprint') - name = config.get(CONF_NAME, "OctoPrint") - monitored_conditions = config.get("monitored_conditions", - SENSOR_TYPES.keys()) + name = config.get(CONF_NAME) + monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) devices = [] types = ["actual", "target"] @@ -46,19 +59,19 @@ def setup_platform(hass, config, add_devices, discovery_info=None): SENSOR_TYPES[octo_type][1], tool) devices.append(new_sensor) - elif octo_type in SENSOR_TYPES: - new_sensor = OctoPrintSensor(octoprint.OCTOPRINT, - octo_type, - SENSOR_TYPES[octo_type][2], - name, - SENSOR_TYPES[octo_type][3], - SENSOR_TYPES[octo_type][0], - SENSOR_TYPES[octo_type][1]) - devices.append(new_sensor) else: _LOGGER.error("Unknown OctoPrint sensor type: %s", octo_type) - add_devices(devices) + new_sensor = OctoPrintSensor(octoprint.OCTOPRINT, + octo_type, + SENSOR_TYPES[octo_type][2], + name, + SENSOR_TYPES[octo_type][3], + SENSOR_TYPES[octo_type][0], + SENSOR_TYPES[octo_type][1]) + devices.append(new_sensor) + + add_devices(devices) # pylint: disable=too-many-instance-attributes @@ -66,14 +79,15 @@ class OctoPrintSensor(Entity): """Representation of an OctoPrint sensor.""" # pylint: disable=too-many-arguments - def __init__(self, api, condition, sensor_type, sensor_name, - unit, endpoint, group, tool=None): + def __init__(self, api, condition, sensor_type, sensor_name, unit, + endpoint, group, tool=None): """Initialize a new OctoPrint sensor.""" self.sensor_name = sensor_name if tool is None: - self._name = sensor_name + ' ' + condition + self._name = '{} {}'.format(sensor_name, condition) else: - self._name = sensor_name + ' ' + condition + ' ' + tool + ' temp' + self._name = '{} {} {} {}'.format( + sensor_name, condition, tool, ' temp') self.sensor_type = sensor_type self.api = api self._state = None diff --git a/homeassistant/components/sensor/onewire.py b/homeassistant/components/sensor/onewire.py index a2a3f0811f2..e7a78393b93 100644 --- a/homeassistant/components/sensor/onewire.py +++ b/homeassistant/components/sensor/onewire.py @@ -1,16 +1,28 @@ """ -Support for DS18B20 One Wire Sensors. +Support for 1-Wire temperature sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.onewire/ """ -import logging import os import time +import logging from glob import glob - -from homeassistant.const import STATE_UNKNOWN, TEMP_CELSIUS +import voluptuous as vol from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.const import STATE_UNKNOWN, TEMP_CELSIUS +from homeassistant.components.sensor import PLATFORM_SCHEMA + +CONF_MOUNT_DIR = 'mount_dir' +CONF_NAMES = 'names' +DEFAULT_MOUNT_DIR = '/sys/bus/w1/devices/' +DEVICE_FAMILIES = ('10', '22', '28', '3B', '42') + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAMES): {cv.string: cv.string}, + vol.Optional(CONF_MOUNT_DIR, default=DEFAULT_MOUNT_DIR): cv.string, +}) _LOGGER = logging.getLogger(__name__) @@ -18,22 +30,22 @@ _LOGGER = logging.getLogger(__name__) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the one wire Sensors.""" - base_dir = config.get('mount_dir', '/sys/bus/w1/devices/') - device_folders = glob(os.path.join(base_dir, '[10,22,28,3B,42]*')) + base_dir = config.get(CONF_MOUNT_DIR) sensor_ids = [] device_files = [] - for device_folder in device_folders: - sensor_ids.append(os.path.split(device_folder)[1]) - if base_dir.startswith('/sys/bus/w1/devices'): - device_files.append(os.path.join(device_folder, 'w1_slave')) - else: - device_files.append(os.path.join(device_folder, 'temperature')) + for device_family in DEVICE_FAMILIES: + for device_folder in glob(os.path.join(base_dir, device_family + + '[.-]*')): + sensor_ids.append(os.path.split(device_folder)[1]) + if base_dir == DEFAULT_MOUNT_DIR: + device_files.append(os.path.join(device_folder, 'w1_slave')) + else: + device_files.append(os.path.join(device_folder, 'temperature')) if device_files == []: - _LOGGER.error('No onewire sensor found.') - _LOGGER.error('Check if dtoverlay=w1-gpio,gpiopin=4.') - _LOGGER.error('is in your /boot/config.txt and') - _LOGGER.error('the correct gpiopin number is set.') + _LOGGER.error('No onewire sensor found. Check if ' + 'dtoverlay=w1-gpio is in your /boot/config.txt. ' + 'Check the mount_dir parameter if it\'s defined.') return devs = [] @@ -92,7 +104,7 @@ class OneWire(Entity): def update(self): """Get the latest data from the device.""" temp = -99 - if self._device_file.startswith('/sys/bus/w1/devices'): + if self._device_file.startswith(DEFAULT_MOUNT_DIR): lines = self._read_temp_raw() while lines[0].strip()[-3:] != 'YES': time.sleep(0.2) @@ -102,15 +114,18 @@ class OneWire(Entity): temp_string = lines[1][equals_pos+2:] temp = round(float(temp_string) / 1000.0, 1) else: - ds_device_file = open(self._device_file, 'r') - temp_read = ds_device_file.readlines() - ds_device_file.close() - if len(temp_read) == 1: - try: + try: + ds_device_file = open(self._device_file, 'r') + temp_read = ds_device_file.readlines() + ds_device_file.close() + if len(temp_read) == 1: temp = round(float(temp_read[0]), 1) - except ValueError: - _LOGGER.warning('Invalid temperature value read from ' + - self._device_file) + except ValueError: + _LOGGER.warning('Invalid temperature value read from ' + + self._device_file) + except FileNotFoundError: + _LOGGER.warning('Cannot read from sensor: ' + + self._device_file) if temp < -55 or temp > 125: return diff --git a/homeassistant/components/sensor/openweathermap.py b/homeassistant/components/sensor/openweathermap.py index efaa8d450b4..e7936cc0535 100644 --- a/homeassistant/components/sensor/openweathermap.py +++ b/homeassistant/components/sensor/openweathermap.py @@ -9,14 +9,24 @@ from datetime import timedelta import voluptuous as vol -from homeassistant.const import (CONF_API_KEY, TEMP_CELSIUS, TEMP_FAHRENHEIT, - CONF_PLATFORM, CONF_MONITORED_CONDITIONS) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_API_KEY, CONF_NAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, + CONF_MONITORED_CONDITIONS) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pyowm==2.3.2'] +REQUIREMENTS = ['pyowm==2.4.0'] + _LOGGER = logging.getLogger(__name__) + +CONF_FORECAST = 'forecast' + +DEFAULT_NAME = 'OWM' + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) + SENSOR_TYPES = { 'weather': ['Condition', None], 'temperature': ['Temperature', None], @@ -28,17 +38,14 @@ SENSOR_TYPES = { 'snow': ['Snow', 'mm'] } -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'openweathermap', - vol.Required(CONF_API_KEY): vol.Coerce(str), +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): - [vol.In(SENSOR_TYPES.keys())], - vol.Optional('forecast', default=False): cv.boolean + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_FORECAST, default=False): cv.boolean }) -# Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) - def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the OpenWeatherMap sensor.""" @@ -49,32 +56,29 @@ def setup_platform(hass, config, add_devices, discovery_info=None): from pyowm import OWM SENSOR_TYPES['temperature'][1] = hass.config.units.temperature_unit - forecast = config.get('forecast') - owm = OWM(config.get(CONF_API_KEY, None)) + + name = config.get(CONF_NAME) + forecast = config.get(CONF_FORECAST) + + owm = OWM(config.get(CONF_API_KEY)) if not owm: _LOGGER.error( "Connection error " - "Please check your settings for OpenWeatherMap.") + "Please check your settings for OpenWeatherMap") return False data = WeatherData(owm, forecast, hass.config.latitude, hass.config.longitude) dev = [] - try: - for variable in config['monitored_conditions']: - if variable not in SENSOR_TYPES: - _LOGGER.error('Sensor type: "%s" does not exist', variable) - else: - dev.append(OpenWeatherMapSensor(data, variable, - SENSOR_TYPES[variable][1])) - except KeyError: - pass + for variable in config[CONF_MONITORED_CONDITIONS]: + dev.append(OpenWeatherMapSensor( + name, data, variable, SENSOR_TYPES[variable][1])) if forecast: SENSOR_TYPES['forecast'] = ['Forecast', None] - dev.append(OpenWeatherMapSensor(data, 'forecast', - SENSOR_TYPES['temperature'][1])) + dev.append(OpenWeatherMapSensor( + name, data, 'forecast', SENSOR_TYPES['temperature'][1])) add_devices(dev) @@ -83,9 +87,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class OpenWeatherMapSensor(Entity): """Implementation of an OpenWeatherMap sensor.""" - def __init__(self, weather_data, sensor_type, temp_unit): + def __init__(self, name, weather_data, sensor_type, temp_unit): """Initialize the sensor.""" - self.client_name = 'Weather' + self.client_name = name self._name = SENSOR_TYPES[sensor_type][0] self.owa_client = weather_data self.temp_unit = temp_unit diff --git a/homeassistant/components/sensor/pi_hole.py b/homeassistant/components/sensor/pi_hole.py new file mode 100644 index 00000000000..7cd3423bf65 --- /dev/null +++ b/homeassistant/components/sensor/pi_hole.py @@ -0,0 +1,101 @@ +""" +Support for getting statistical data from a Pi-Hole system. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.pi_hole/ +""" +import logging +import json + +import voluptuous as vol + +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.components.sensor.rest import RestData +from homeassistant.const import ( + CONF_NAME, CONF_HOST, CONF_SSL, CONF_VERIFY_SSL) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) +_ENDPOINT = '/admin/api.php' + +ATTR_BLOCKED_DOMAINS = 'domains_blocked' +ATTR_PERCENTAGE_TODAY = 'percentage_today' +ATTR_QUERIES_TODAY = 'queries_today' + +DEFAULT_HOST = 'localhost' +DEFAULT_METHOD = 'GET' +DEFAULT_NAME = 'Pi-hole' +DEFAULT_SSL = False +DEFAULT_VERIFY_SSL = True + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Pi-Hole sensor.""" + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + method = 'GET' + payload = None + verify_ssl = config.get(CONF_VERIFY_SSL) + use_ssl = config.get(CONF_SSL) + + if use_ssl: + uri_scheme = 'https://' + else: + uri_scheme = 'http://' + + resource = "{}{}{}".format(uri_scheme, host, _ENDPOINT) + + rest = RestData(method, resource, payload, verify_ssl) + rest.update() + + if rest.data is None: + _LOGGER.error('Unable to fetch REST data') + return False + + add_devices([PiHoleSensor(hass, rest, name)]) + + +class PiHoleSensor(Entity): + """Representation of a Pi-Hole sensor.""" + + def __init__(self, hass, rest, name): + """Initialize a Pi-Hole sensor.""" + self._hass = hass + self.rest = rest + self._name = name + self._state = False + self.update() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + # pylint: disable=no-member + @property + def state(self): + """Return the state of the device.""" + return self._state.get('ads_blocked_today') + + # pylint: disable=no-member + @property + def state_attributes(self): + """Return the state attributes of the GPS.""" + return { + ATTR_BLOCKED_DOMAINS: self._state.get('domains_being_blocked'), + ATTR_PERCENTAGE_TODAY: self._state.get('ads_percentage_today'), + ATTR_QUERIES_TODAY: self._state.get('dns_queries_today'), + } + + def update(self): + """Get the latest data from REST API and updates the state.""" + self.rest.update() + self._state = json.loads(self.rest.data) diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index 022477d77a9..def47c79f4d 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -12,13 +12,16 @@ import requests from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_PAYLOAD, CONF_NAME, CONF_VALUE_TEMPLATE, CONF_METHOD, CONF_RESOURCE, - CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN) + CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, CONF_VERIFY_SSL) from homeassistant.helpers.entity import Entity from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv +_LOGGER = logging.getLogger(__name__) + DEFAULT_METHOD = 'GET' DEFAULT_NAME = 'REST Sensor' +DEFAULT_VERIFY_SSL = True PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_RESOURCE): cv.url, @@ -27,10 +30,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PAYLOAD): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, }) -_LOGGER = logging.getLogger(__name__) - # pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): @@ -39,7 +41,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): resource = config.get(CONF_RESOURCE) method = config.get(CONF_METHOD) payload = config.get(CONF_PAYLOAD) - verify_ssl = config.get('verify_ssl', True) + verify_ssl = config.get(CONF_VERIFY_SSL) unit = config.get(CONF_UNIT_OF_MEASUREMENT) value_template = config.get(CONF_VALUE_TEMPLATE) @@ -58,7 +60,7 @@ class RestSensor(Entity): """Implementation of a REST sensor.""" def __init__(self, hass, rest, name, unit_of_measurement, value_template): - """Initialize the sensor.""" + """Initialize the REST sensor.""" self._hass = hass self.rest = rest self._name = name diff --git a/homeassistant/components/sensor/rfxtrx.py b/homeassistant/components/sensor/rfxtrx.py index f9f7270c8e3..60afd80997d 100644 --- a/homeassistant/components/sensor/rfxtrx.py +++ b/homeassistant/components/sensor/rfxtrx.py @@ -9,6 +9,7 @@ import voluptuous as vol import homeassistant.components.rfxtrx as rfxtrx import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_PLATFORM from homeassistant.helpers.entity import Entity from homeassistant.util import slugify from homeassistant.components.rfxtrx import ( @@ -20,7 +21,7 @@ DEPENDENCIES = ['rfxtrx'] _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = vol.Schema({ - vol.Required("platform"): rfxtrx.DOMAIN, + vol.Required(CONF_PLATFORM): rfxtrx.DOMAIN, vol.Optional(CONF_DEVICES, default={}): vol.All(dict, rfxtrx.valid_sensor), vol.Optional(ATTR_AUTOMATIC_ADD, default=False): cv.boolean, }, extra=vol.ALLOW_EXTRA) @@ -31,7 +32,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): # pylint: disable=too-many-locals from RFXtrx import SensorEvent sensors = [] - for packet_id, entity_info in config['devices'].items(): + for packet_id, entity_info in config[CONF_DEVICES].items(): event = rfxtrx.get_rfx_object(packet_id) device_id = "sensor_" + slugify(event.device.id_string.lower()) if device_id in rfxtrx.RFX_DEVICES: @@ -41,7 +42,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): sub_sensors = {} data_types = entity_info[ATTR_DATA_TYPE] if len(data_types) == 0: - data_types = ["Unknown"] + data_types = [''] for data_type in DATA_TYPES: if data_type in event.values: data_types = [data_type] @@ -52,7 +53,6 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): sensors.append(new_sensor) sub_sensors[_data_type] = new_sensor rfxtrx.RFX_DEVICES[device_id] = sub_sensors - add_devices_callback(sensors) def sensor_update(event): @@ -75,7 +75,6 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): sensors[key].entity_id, } ) - return # Add entity if not exist and the automatic_add is True @@ -86,7 +85,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): _LOGGER.info("Automatic add rfxtrx.sensor: %s", pkt_id) - data_type = "Unknown" + data_type = '' for _data_type in DATA_TYPES: if _data_type in event.values: data_type = _data_type @@ -119,9 +118,9 @@ class RfxtrxSensor(Entity): @property def state(self): """Return the state of the sensor.""" - if self.event and self.data_type in self.event.values: - return self.event.values[self.data_type] - return None + if not self.event: + return None + return self.event.values.get(self.data_type) @property def name(self): @@ -131,8 +130,9 @@ class RfxtrxSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" - if self.event: - return self.event.values + if not self.event: + return None + return self.event.values @property def unit_of_measurement(self): diff --git a/homeassistant/components/sensor/sabnzbd.py b/homeassistant/components/sensor/sabnzbd.py index a11d65d22bf..0f33a39bbcc 100644 --- a/homeassistant/components/sensor/sabnzbd.py +++ b/homeassistant/components/sensor/sabnzbd.py @@ -11,7 +11,8 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_API_KEY, CONF_NAME, CONF_PORT, CONF_MONITORED_VARIABLES) + CONF_HOST, CONF_API_KEY, CONF_NAME, CONF_PORT, CONF_MONITORED_VARIABLES, + CONF_SSL) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -25,6 +26,7 @@ _THROTTLED_REFRESH = None DEFAULT_NAME = 'SABnzbd' DEFAULT_PORT = 8080 +DEFAULT_SSL = False MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) @@ -44,10 +46,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, }) -# pylint: disable=unused-argument +# pylint: disable=unused-argument, too-many-locals def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the SABnzbd sensors.""" from pysabnzbd import SabnzbdApi, SabnzbdApiException @@ -57,14 +60,21 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) api_key = config.get(CONF_API_KEY) monitored_types = config.get(CONF_MONITORED_VARIABLES) - base_url = "http://{}:{}/".format(host, port) + use_ssl = config.get(CONF_SSL) + + if use_ssl: + uri_scheme = 'https://' + else: + uri_scheme = 'http://' + + base_url = "{}{}:{}/".format(uri_scheme, host, port) sab_api = SabnzbdApi(base_url, api_key) try: sab_api.check_available() except SabnzbdApiException: - _LOGGER.exception("Connection to SABnzbd API failed") + _LOGGER.error("Connection to SABnzbd API failed") return False # pylint: disable=global-statement diff --git a/homeassistant/components/sensor/steam_online.py b/homeassistant/components/sensor/steam_online.py index a94eed9702e..ed12d4f7844 100644 --- a/homeassistant/components/sensor/steam_online.py +++ b/homeassistant/components/sensor/steam_online.py @@ -4,12 +4,24 @@ Sensor for Steam account status. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.steam_online/ """ +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.const import CONF_API_KEY +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['steamodd==4.21'] + +CONF_ACCOUNTS = 'accounts' ICON = 'mdi:steam' -REQUIREMENTS = ['steamodd==4.21'] +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_ACCOUNTS, default=[]): + vol.All(cv.ensure_list, [cv.string]), +}) # pylint: disable=unused-argument @@ -19,7 +31,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): steamod.api.key.set(config.get(CONF_API_KEY)) add_devices( [SteamSensor(account, - steamod) for account in config.get('accounts', [])]) + steamod) for account in config.get(CONF_ACCOUNTS)]) class SteamSensor(Entity): diff --git a/homeassistant/components/sensor/supervisord.py b/homeassistant/components/sensor/supervisord.py index cebdfb83f14..22c1285a547 100644 --- a/homeassistant/components/sensor/supervisord.py +++ b/homeassistant/components/sensor/supervisord.py @@ -7,28 +7,41 @@ https://home-assistant.io/components/sensor.supervisord/ import logging import xmlrpc.client +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_URL from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +DEFAULT_URL = 'http://localhost:9001/RPC2' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_URL, default=DEFAULT_URL): cv.url, +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Supervisord platform.""" + url = config.get(CONF_URL) try: - supervisor_server = xmlrpc.client.ServerProxy( - config.get('url', 'http://localhost:9001/RPC2')) + supervisor_server = xmlrpc.client.ServerProxy(url) except ConnectionRefusedError: _LOGGER.error('Could not connect to Supervisord') - return + return False + processes = supervisor_server.supervisor.getAllProcessInfo() + add_devices( [SupervisorProcessSensor(info, supervisor_server) for info in processes]) class SupervisorProcessSensor(Entity): - """Represent a supervisor-monitored process.""" + """Representation of a supervisor-monitored process.""" # pylint: disable=abstract-method def __init__(self, info, server): diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 893ec8154c4..125a2871f28 100755 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -8,14 +8,13 @@ import logging import voluptuous as vol -import homeassistant.util.dt as dt_util - -from homeassistant.const import (CONF_RESOURCES, STATE_OFF, STATE_ON) from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_RESOURCES, STATE_OFF, STATE_ON) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util -REQUIREMENTS = ['psutil==4.3.0'] +REQUIREMENTS = ['psutil==4.3.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/temper.py b/homeassistant/components/sensor/temper.py index fe5ebb17982..b7fcdd1b015 100644 --- a/homeassistant/components/sensor/temper.py +++ b/homeassistant/components/sensor/temper.py @@ -5,39 +5,66 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.temper/ """ import logging +import voluptuous as vol +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME, TEMP_FAHRENHEIT from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['https://github.com/rkabadi/temper-python/archive/' - '3dbdaf2d87b8db9a3cd6e5585fc704537dd2d09b.zip' - '#temperusb==1.2.3'] +REQUIREMENTS = ['temperusb==1.5.1'] + +CONF_SCALE = 'scale' +CONF_OFFSET = 'offset' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEVICE_DEFAULT_NAME): vol.Coerce(str), + vol.Optional(CONF_SCALE, default=1): vol.Coerce(float), + vol.Optional(CONF_OFFSET, default=0): vol.Coerce(float) +}) # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Temper sensors.""" from temperusb.temper import TemperHandler temp_unit = hass.config.units.temperature_unit - name = config.get(CONF_NAME, DEVICE_DEFAULT_NAME) + name = config.get(CONF_NAME) + scaling = { + 'scale': config.get(CONF_SCALE), + 'offset': config.get(CONF_OFFSET) + } temper_devices = TemperHandler().get_devices() - add_devices_callback([TemperSensor(dev, temp_unit, name + '_' + str(idx)) - for idx, dev in enumerate(temper_devices)]) + devices = [] + + for idx, dev in enumerate(temper_devices): + if idx != 0: + name = name + '_' + str(idx) + devices.append(TemperSensor(dev, temp_unit, name, scaling)) + + add_devices(devices) class TemperSensor(Entity): """Representation of a Temper temperature sensor.""" - def __init__(self, temper_device, temp_unit, name): + def __init__(self, temper_device, temp_unit, name, scaling): """Initialize the sensor.""" self.temper_device = temper_device self.temp_unit = temp_unit + self.scale = scaling['scale'] + self.offset = scaling['offset'] self.current_value = None self._name = name + # set calibration data + self.temper_device.set_calibration_data( + scale=self.scale, + offset=self.offset + ) + @property def name(self): """Return the name of the temperature sensor.""" @@ -58,7 +85,8 @@ class TemperSensor(Entity): try: format_str = ('fahrenheit' if self.temp_unit == TEMP_FAHRENHEIT else 'celsius') - self.current_value = self.temper_device.get_temperature(format_str) + sensor_value = self.temper_device.get_temperature(format_str) + self.current_value = round(sensor_value, 1) except IOError: _LOGGER.error('Failed to get temperature due to insufficient ' 'permissions. Try running with "sudo"') diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index 961b6f39c17..743a1909ea5 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -5,20 +5,20 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.template/ """ import logging + import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import ENTITY_ID_FORMAT, PLATFORM_SCHEMA from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, - ATTR_ENTITY_ID, MATCH_ALL) + ATTR_ENTITY_ID, MATCH_ALL, CONF_SENSORS) from homeassistant.exceptions import TemplateError -from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.helpers import template +from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.helpers.event import track_state_change +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_SENSORS = 'sensors' SENSOR_SCHEMA = vol.Schema({ vol.Required(CONF_VALUE_TEMPLATE): cv.template, @@ -80,8 +80,7 @@ class SensorTemplate(Entity): """Called when the target device changes state.""" self.update_ha_state(True) - track_state_change(hass, entity_ids, - template_sensor_state_listener) + track_state_change(hass, entity_ids, template_sensor_state_listener) @property def name(self): diff --git a/homeassistant/components/sensor/torque.py b/homeassistant/components/sensor/torque.py index 55c6aef31d0..c05217692ac 100644 --- a/homeassistant/components/sensor/torque.py +++ b/homeassistant/components/sensor/torque.py @@ -4,19 +4,28 @@ Support for the Torque OBD application. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.torque/ """ - +import logging import re -from homeassistant.helpers.entity import Entity -from homeassistant.components.http import HomeAssistantView +import voluptuous as vol -DOMAIN = 'torque' -DEPENDENCIES = ['http'] -SENSOR_EMAIL_FIELD = 'eml' -DEFAULT_NAME = 'vehicle' -ENTITY_NAME_FORMAT = '{0} {1}' +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_EMAIL, CONF_NAME) +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) API_PATH = '/api/torque' + +DEFAULT_NAME = 'vehicle' +DEPENDENCIES = ['http'] +DOMAIN = 'torque' + +ENTITY_NAME_FORMAT = '{0} {1}' + +SENSOR_EMAIL_FIELD = 'eml' SENSOR_NAME_KEY = r'userFullName(\w+)' SENSOR_UNIT_KEY = r'userUnit(\w+)' SENSOR_VALUE_KEY = r'k(\w+)' @@ -25,6 +34,11 @@ NAME_KEY = re.compile(SENSOR_NAME_KEY) UNIT_KEY = re.compile(SENSOR_UNIT_KEY) VALUE_KEY = re.compile(SENSOR_VALUE_KEY) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_EMAIL): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def decode(value): """Double-decode required.""" @@ -39,12 +53,12 @@ def convert_pid(value): # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup Torque platform.""" - vehicle = config.get('name', DEFAULT_NAME) - email = config.get('email', None) + vehicle = config.get(CONF_NAME) + email = config.get(CONF_EMAIL) sensors = {} - hass.wsgi.register_view(TorqueReceiveDataView(hass, email, vehicle, - sensors, add_devices)) + hass.wsgi.register_view(TorqueReceiveDataView( + hass, email, vehicle, sensors, add_devices)) return True diff --git a/homeassistant/components/sensor/uber.py b/homeassistant/components/sensor/uber.py index a27f8ca4def..7f250431984 100644 --- a/homeassistant/components/sensor/uber.py +++ b/homeassistant/components/sensor/uber.py @@ -7,50 +7,61 @@ https://home-assistant.io/components/sensor.uber/ import logging from datetime import timedelta +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['uber_rides==0.2.5'] _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ["uber_rides==0.2.4"] -ICON = "mdi:taxi" +CONF_END_LATITUDE = 'end_latitude' +CONF_END_LONGITUDE = 'end_longitude' +CONF_PRODUCT_IDS = 'product_ids' +CONF_SERVER_TOKEN = 'server_token' +CONF_START_LATITUDE = 'start_latitude' +CONF_START_LONGITUDE = 'start_longitude' + +ICON = 'mdi:taxi' -# Return cached results if last scan was less then this time ago. MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SERVER_TOKEN): cv.string, + vol.Required(CONF_START_LATITUDE): cv.latitude, + vol.Required(CONF_START_LONGITUDE): cv.longitude, + vol.Optional(CONF_END_LATITUDE): cv.latitude, + vol.Optional(CONF_END_LONGITUDE): cv.longitude, + vol.Optional(CONF_PRODUCT_IDS, default=[]): + vol.All(cv.ensure_list, [cv.string]), +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Uber sensor.""" - if None in (config.get("start_latitude"), config.get("start_longitude")): - _LOGGER.error( - "You must set start latitude and longitude to use the Uber sensor!" - ) - return False - - if config.get("server_token") is None: - _LOGGER.error("You must set a server_token to use the Uber sensor!") - return False - from uber_rides.session import Session - session = Session(server_token=config.get("server_token")) + session = Session(server_token=config.get(CONF_SERVER_TOKEN)) - wanted_product_ids = config.get("product_ids") + wanted_product_ids = config.get(CONF_PRODUCT_IDS) dev = [] - timeandpriceest = UberEstimate(session, config["start_latitude"], - config["start_longitude"], - config.get("end_latitude"), - config.get("end_longitude")) + timeandpriceest = UberEstimate(session, config[CONF_START_LATITUDE], + config[CONF_START_LONGITUDE], + config.get(CONF_END_LATITUDE), + config.get(CONF_END_LONGITUDE)) for product_id, product in timeandpriceest.products.items(): if (wanted_product_ids is not None) and \ (product_id not in wanted_product_ids): continue - dev.append(UberSensor("time", timeandpriceest, product_id, product)) - if (product.get("price_details") is not None) and \ - product["price_details"]["estimate"] is not "Metered": - dev.append(UberSensor("price", timeandpriceest, - product_id, product)) + dev.append(UberSensor('time', timeandpriceest, product_id, product)) + if (product.get('price_details') is not None) and \ + product['price_details']['estimate'] is not 'Metered': + dev.append(UberSensor( + 'price', timeandpriceest, product_id, product)) add_devices(dev) @@ -64,20 +75,20 @@ class UberSensor(Entity): self._product_id = product_id self._product = product self._sensortype = sensorType - self._name = "{} {}".format(self._product["display_name"], + self._name = '{} {}'.format(self._product['display_name'], self._sensortype) - if self._sensortype == "time": - self._unit_of_measurement = "min" - time_estimate = self._product.get("time_estimate_seconds", 0) + if self._sensortype == 'time': + self._unit_of_measurement = 'min' + time_estimate = self._product.get('time_estimate_seconds', 0) self._state = int(time_estimate / 60) - elif self._sensortype == "price": - if self._product.get("price_details") is not None: - price_details = self._product["price_details"] - self._unit_of_measurement = price_details.get("currency_code") - if price_details.get("low_estimate") is not None: - statekey = "minimum" + elif self._sensortype == 'price': + if self._product.get('price_details') is not None: + price_details = self._product['price_details'] + self._unit_of_measurement = price_details.get('currency_code') + if price_details.get('low_estimate') is not None: + statekey = 'minimum' else: - statekey = "low_estimate" + statekey = 'low_estimate' self._state = int(price_details.get(statekey, 0)) else: self._state = 0 @@ -86,8 +97,8 @@ class UberSensor(Entity): @property def name(self): """Return the name of the sensor.""" - if "uber" not in self._name.lower(): - self._name = "Uber{}".format(self._name) + if 'uber' not in self._name.lower(): + self._name = 'Uber{}'.format(self._name) return self._name @property @@ -105,35 +116,35 @@ class UberSensor(Entity): """Return the state attributes.""" time_estimate = self._product.get("time_estimate_seconds") params = { - "Product ID": self._product["product_id"], - "Product short description": self._product["short_description"], - "Product display name": self._product["display_name"], - "Product description": self._product["description"], - "Pickup time estimate (in seconds)": time_estimate, - "Trip duration (in seconds)": self._product.get("duration"), - "Vehicle Capacity": self._product["capacity"] + 'Product ID': self._product['product_id'], + 'Product short description': self._product['short_description'], + 'Product display name': self._product['display_name'], + 'Product description': self._product['description'], + 'Pickup time estimate (in seconds)': time_estimate, + 'Trip duration (in seconds)': self._product.get('duration'), + 'Vehicle Capacity': self._product['capacity'] } - if self._product.get("price_details") is not None: - price_details = self._product["price_details"] - dunit = price_details.get("distance_unit") - distance_key = "Trip distance (in {}s)".format(dunit) - distance_val = self._product.get("distance") - params["Cost per minute"] = price_details.get("cost_per_minute") - params["Distance units"] = price_details.get("distance_unit") - params["Cancellation fee"] = price_details.get("cancellation_fee") - cpd = price_details.get("cost_per_distance") - params["Cost per distance"] = cpd - params["Base price"] = price_details.get("base") - params["Minimum price"] = price_details.get("minimum") - params["Price estimate"] = price_details.get("estimate") - params["Price currency code"] = price_details.get("currency_code") - params["High price estimate"] = price_details.get("high_estimate") - params["Low price estimate"] = price_details.get("low_estimate") - params["Surge multiplier"] = price_details.get("surge_multiplier") + if self._product.get('price_details') is not None: + price_details = self._product['price_details'] + dunit = price_details.get('distance_unit') + distance_key = 'Trip distance (in {}s)'.format(dunit) + distance_val = self._product.get('distance') + params['Cost per minute'] = price_details.get('cost_per_minute') + params['Distance units'] = price_details.get('distance_unit') + params['Cancellation fee'] = price_details.get('cancellation_fee') + cpd = price_details.get('cost_per_distance') + params['Cost per distance'] = cpd + params['Base price'] = price_details.get('base') + params['Minimum price'] = price_details.get('minimum') + params['Price estimate'] = price_details.get('estimate') + params['Price currency code'] = price_details.get('currency_code') + params['High price estimate'] = price_details.get('high_estimate') + params['Low price estimate'] = price_details.get('low_estimate') + params['Surge multiplier'] = price_details.get('surge_multiplier') else: - distance_key = "Trip distance (in miles)" - distance_val = self._product.get("distance") + distance_key = 'Trip distance (in miles)' + distance_val = self._product.get('distance') params[distance_key] = distance_val @@ -149,14 +160,14 @@ class UberSensor(Entity): """Get the latest data from the Uber API and update the states.""" self.data.update() self._product = self.data.products[self._product_id] - if self._sensortype == "time": - time_estimate = self._product.get("time_estimate_seconds", 0) + if self._sensortype == 'time': + time_estimate = self._product.get('time_estimate_seconds', 0) self._state = int(time_estimate / 60) - elif self._sensortype == "price": - price_details = self._product.get("price_details") + elif self._sensortype == 'price': + price_details = self._product.get('price_details') if price_details is not None: - min_price = price_details.get("minimum") - self._state = int(price_details.get("low_estimate", min_price)) + min_price = price_details.get('minimum') + self._state = int(price_details.get('low_estimate', min_price)) else: self._state = 0 @@ -188,39 +199,39 @@ class UberEstimate(object): products_response = client.get_products( self.start_latitude, self.start_longitude) - products = products_response.json.get("products") + products = products_response.json.get('products') for product in products: - self.products[product["product_id"]] = product + self.products[product['product_id']] = product if self.end_latitude is not None and self.end_longitude is not None: price_response = client.get_price_estimates( self.start_latitude, self.start_longitude, self.end_latitude, self.end_longitude) - prices = price_response.json.get("prices", []) + prices = price_response.json.get('prices', []) for price in prices: - product = self.products[price["product_id"]] - product["duration"] = price.get("duration", "0") - product["distance"] = price.get("distance", "0") - price_details = product.get("price_details") - if product.get("price_details") is None: + product = self.products[price['product_id']] + product['duration'] = price.get('duration', '0') + product['distance'] = price.get('distance', '0') + price_details = product.get('price_details') + if product.get('price_details') is None: price_details = {} - price_details["estimate"] = price.get("estimate", "0") - price_details["high_estimate"] = price.get("high_estimate", - "0") - price_details["low_estimate"] = price.get("low_estimate", "0") - price_details["currency_code"] = price.get("currency_code") - surge_multiplier = price.get("surge_multiplier", "0") - price_details["surge_multiplier"] = surge_multiplier - product["price_details"] = price_details + price_details['estimate'] = price.get('estimate', '0') + price_details['high_estimate'] = price.get('high_estimate', + '0') + price_details['low_estimate'] = price.get('low_estimate', '0') + price_details['currency_code'] = price.get('currency_code') + surge_multiplier = price.get('surge_multiplier', '0') + price_details['surge_multiplier'] = surge_multiplier + product['price_details'] = price_details estimate_response = client.get_pickup_time_estimates( self.start_latitude, self.start_longitude) - estimates = estimate_response.json.get("times") + estimates = estimate_response.json.get('times') for estimate in estimates: - self.products[estimate["product_id"]][ - "time_estimate_seconds"] = estimate.get("estimate", "0") + self.products[estimate['product_id']][ + 'time_estimate_seconds'] = estimate.get('estimate', '0') diff --git a/homeassistant/components/sensor/verisure.py b/homeassistant/components/sensor/verisure.py index 4252c9d8b33..932da40bc9f 100644 --- a/homeassistant/components/sensor/verisure.py +++ b/homeassistant/components/sensor/verisure.py @@ -2,11 +2,13 @@ Interfaces with Verisure sensors. For more details about this platform, please refer to the documentation at -documentation at https://home-assistant.io/components/verisure/ +https://home-assistant.io/components/sensor.verisure/ """ import logging from homeassistant.components.verisure import HUB as hub +from homeassistant.components.verisure import ( + CONF_THERMOMETERS, CONF_HYDROMETERS, CONF_MOUSE) from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers.entity import Entity @@ -17,7 +19,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Verisure platform.""" sensors = [] - if int(hub.config.get('thermometers', '1')): + if int(hub.config.get(CONF_THERMOMETERS, 1)): hub.update_climate() sensors.extend([ VerisureThermometer(value.id) @@ -25,7 +27,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if hasattr(value, 'temperature') and value.temperature ]) - if int(hub.config.get('hygrometers', '1')): + if int(hub.config.get(CONF_HYDROMETERS, 1)): hub.update_climate() sensors.extend([ VerisureHygrometer(value.id) @@ -33,7 +35,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if hasattr(value, 'humidity') and value.humidity ]) - if int(hub.config.get('mouse', '1')): + if int(hub.config.get(CONF_MOUSE, 1)): hub.update_mousedetection() sensors.extend([ VerisureMouseDetection(value.deviceLabel) @@ -56,8 +58,7 @@ class VerisureThermometer(Entity): def name(self): """Return the name of the device.""" return '{} {}'.format( - hub.climate_status[self._id].location, - "Temperature") + hub.climate_status[self._id].location, 'Temperature') @property def state(self): @@ -91,8 +92,7 @@ class VerisureHygrometer(Entity): def name(self): """Return the name of the sensor.""" return '{} {}'.format( - hub.climate_status[self._id].location, - "Humidity") + hub.climate_status[self._id].location, 'Humidity') @property def state(self): @@ -126,8 +126,7 @@ class VerisureMouseDetection(Entity): def name(self): """Return the name of the sensor.""" return '{} {}'.format( - hub.mouse_status[self._id].location, - "Mouse") + hub.mouse_status[self._id].location, 'Mouse') @property def state(self): diff --git a/homeassistant/components/sensor/xbox_live.py b/homeassistant/components/sensor/xbox_live.py new file mode 100644 index 00000000000..90983e1df83 --- /dev/null +++ b/homeassistant/components/sensor/xbox_live.py @@ -0,0 +1,112 @@ +""" +Sensor for Xbox Live account status. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.xbox_live/ +""" +import logging +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_API_KEY, STATE_UNKNOWN) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ICON = 'mdi:xbox' + +REQUIREMENTS = ['xboxapi==0.1.1'] + +CONF_XUID = 'xuid' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_XUID): vol.All(cv.ensure_list, [cv.string]) +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Xbox platform.""" + from xboxapi import xbox_api + api = xbox_api.XboxApi(config.get(CONF_API_KEY)) + devices = [] + + for xuid in config.get(CONF_XUID): + new_device = XboxSensor(hass, api, xuid) + if new_device.success_init: + devices.append(new_device) + + if len(devices) > 0: + add_devices(devices) + else: + return False + + +# pylint: disable=too-many-instance-attributes +class XboxSensor(Entity): + """A class for the Xbox account.""" + + def __init__(self, hass, api, xuid): + """Initialize the sensor.""" + self._hass = hass + self._state = STATE_UNKNOWN + self._presence = {} + self._xuid = xuid + self._api = api + + # get profile info + profile = self._api.get_user_profile(self._xuid) + + if profile.get('success', True) \ + and profile.get('code', 0) != 28: + self.success_init = True + self._gamertag = profile.get('Gamertag') + self._picture = profile.get('GameDisplayPicRaw') + else: + self.success_init = False + + @property + def name(self): + """Return the name of the sensor.""" + return self._gamertag + + @property + def entity_id(self): + """Return the entity ID.""" + return 'sensor.xbox_' + self._gamertag + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attributes = {} + for device in self._presence: + for title in device.get('titles'): + attributes[ + '{} {}'.format(device.get('type'), + title.get('placement')) + ] = title.get('name') + + return attributes + + @property + def entity_picture(self): + """Avatar of the account.""" + return self._picture + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return ICON + + def update(self): + """Update state data from Xbox API.""" + presence = self._api.get_user_presence(self._xuid) + self._state = presence.get('state', STATE_UNKNOWN) + self._presence = presence.get('devices', {}) diff --git a/homeassistant/components/sensor/zigbee.py b/homeassistant/components/sensor/zigbee.py index 2692bcf9715..6b455230aa6 100644 --- a/homeassistant/components/sensor/zigbee.py +++ b/homeassistant/components/sensor/zigbee.py @@ -7,32 +7,45 @@ https://home-assistant.io/components/sensor.zigbee/ import logging from binascii import hexlify +import voluptuous as vol + from homeassistant.components import zigbee +from homeassistant.components.zigbee import PLATFORM_SCHEMA from homeassistant.const import TEMP_CELSIUS from homeassistant.core import JobPriority from homeassistant.helpers.entity import Entity -DEPENDENCIES = ["zigbee"] _LOGGER = logging.getLogger(__name__) +CONF_TYPE = 'type' +CONF_MAX_VOLTS = 'max_volts' -def setup_platform(hass, config, add_entities, discovery_info=None): - """Setup the Z-Wave platform. +DEFAULT_VOLTS = 1.2 +DEPENDENCIES = ['zigbee'] + +TYPES = ['analog', 'temperature'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TYPE): vol.In(TYPES), + vol.Optional(CONF_MAX_VOLTS, default=DEFAULT_VOLTS): vol.Coerce(float), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the ZigBee platform. Uses the 'type' config value to work out which type of ZigBee sensor we're dealing with and instantiates the relevant classes to handle it. """ - typ = config.get("type", "").lower() - if not typ: - _LOGGER.exception( - "Must include 'type' when configuring a ZigBee sensor.") - return + typ = config.get(CONF_TYPE) + try: sensor_class, config_class = TYPE_CLASSES[typ] except KeyError: _LOGGER.exception("Unknown ZigBee sensor type: %s", typ) return - add_entities([sensor_class(hass, config_class(config))]) + + add_devices([sensor_class(hass, config_class(config))]) class ZigBeeTemperatureSensor(Entity): diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index ac6d9829fc5..4f79a2ee627 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -39,6 +39,11 @@ foursquare: description: Vertical accuracy of the user's location, in meters. example: 1 +group: + reload: + description: "Reload group configuration." + fields: + persistent_notification: create: description: Show a notification in the frontend diff --git a/homeassistant/components/switch/acer_projector.py b/homeassistant/components/switch/acer_projector.py index b0a6a93cb4d..5845e611c31 100644 --- a/homeassistant/components/switch/acer_projector.py +++ b/homeassistant/components/switch/acer_projector.py @@ -1,21 +1,40 @@ """ -Use serial protocol of acer projector to obtain state of the projector. +Use serial protocol of Acer projector to obtain state of the projector. -This component allows to control almost all projectors from acer using -their RS232 serial communication protocol. +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/switch.acer_projector/ """ import logging import re -from homeassistant.components.switch import SwitchDevice -from homeassistant.const import (STATE_ON, STATE_OFF, STATE_UNKNOWN, - CONF_NAME, CONF_FILENAME) +import voluptuous as vol + +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + STATE_ON, STATE_OFF, STATE_UNKNOWN, CONF_NAME, CONF_FILENAME) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyserial==3.1.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_TIMEOUT = 'timeout' +CONF_WRITE_TIMEOUT = 'write_timeout' + +DEFAULT_NAME = 'Acer Projector' +DEFAULT_TIMEOUT = 1 +DEFAULT_WRITE_TIMEOUT = 1 -LAMP_HOURS = 'Lamp Hours' -INPUT_SOURCE = 'Input Source' ECO_MODE = 'ECO Mode' -MODEL = 'Model' + +ICON = 'mdi:projector' + +INPUT_SOURCE = 'Input Source' + LAMP = 'Lamp' +LAMP_HOURS = 'Lamp Hours' + +MODEL = 'Model' # Commands known to the projector CMD_DICT = {LAMP: '* 0 Lamp ?\r', @@ -26,38 +45,34 @@ CMD_DICT = {LAMP: '* 0 Lamp ?\r', STATE_ON: '* 0 IR 001\r', STATE_OFF: '* 0 IR 002\r'} -_LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pyserial<=3.1'] - -ICON = 'mdi:projector' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_FILENAME): cv.isfile, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_WRITE_TIMEOUT, default=DEFAULT_WRITE_TIMEOUT): + cv.positive_int, +}) -# pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Connect with serial port and return Acer Projector.""" - serial_port = config.get(CONF_FILENAME, None) - name = config.get(CONF_NAME, 'Projector') - timeout = config.get('timeout', 1) - write_timeout = config.get('write_timeout', 1) + serial_port = config.get(CONF_FILENAME) + name = config.get(CONF_NAME) + timeout = config.get(CONF_TIMEOUT) + write_timeout = config.get(CONF_WRITE_TIMEOUT) - if not serial_port: - _LOGGER.error('Missing path of serial device') - return - - devices = [] - devices.append(AcerSwitch(serial_port, name, timeout, write_timeout)) - add_devices_callback(devices) + add_devices([AcerSwitch(serial_port, name, timeout, write_timeout)]) class AcerSwitch(SwitchDevice): """Represents an Acer Projector as an switch.""" - def __init__(self, serial_port, name='Projector', - timeout=1, write_timeout=1, **kwargs): + def __init__(self, serial_port, name, timeout, write_timeout, **kwargs): """Init of the Acer projector.""" import serial - self.ser = serial.Serial(port=serial_port, timeout=timeout, - write_timeout=write_timeout, **kwargs) + self.ser = serial.Serial( + port=serial_port, timeout=timeout, write_timeout=write_timeout, + **kwargs) self._serial_port = serial_port self._name = name self._state = False @@ -73,18 +88,17 @@ class AcerSwitch(SwitchDevice): """Write to the projector and read the return.""" import serial ret = "" - # Sometimes the projector won't answer for no reason, - # or the projector was disconnected during runtime. - # Thisway the projector can be reconnected and will still - # work + # Sometimes the projector won't answer for no reason or the projector + # was disconnected during runtime. + # This way the projector can be reconnected and will still work try: if not self.ser.is_open: self.ser.open() msg = msg.encode('utf-8') self.ser.write(msg) - # size is an experience value there is no real limit. - # AFAIK there is no limit and no end character so - # we will usually need to wait for timeout + # Size is an experience value there is no real limit. + # AFAIK there is no limit and no end character so we will usually + # need to wait for timeout ret = self.ser.read_until(size=20).decode('utf-8') except serial.SerialException: _LOGGER.error('Problem comunicating with %s', self._serial_port) diff --git a/homeassistant/components/switch/command_line.py b/homeassistant/components/switch/command_line.py index 40b83371f9a..e20a47cf084 100644 --- a/homeassistant/components/switch/command_line.py +++ b/homeassistant/components/switch/command_line.py @@ -7,30 +7,53 @@ https://home-assistant.io/components/switch.command_line/ import logging import subprocess -from homeassistant.components.switch import SwitchDevice -from homeassistant.const import CONF_VALUE_TEMPLATE +import voluptuous as vol + +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_FRIENDLY_NAME, CONF_SWITCHES, CONF_VALUE_TEMPLATE, CONF_COMMAND_OFF, + CONF_COMMAND_ON, CONF_COMMAND_STATE) from homeassistant.helpers import template +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +SWITCH_SCHEMA = vol.Schema({ + vol.Optional(CONF_COMMAND_OFF, default='true'): cv.string, + vol.Optional(CONF_COMMAND_ON, default='true'): cv.string, + vol.Optional(CONF_COMMAND_STATE): cv.string, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SWITCHES): vol.Schema({cv.slug: SWITCH_SCHEMA}), +}) + # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return switches controlled by shell commands.""" - switches = config.get('switches', {}) - devices = [] + devices = config.get(CONF_SWITCHES, {}) + switches = [] - for dev_name, properties in switches.items(): - devices.append( + for device_name, device_config in devices.items(): + switches.append( CommandSwitch( hass, - properties.get('name', dev_name), - properties.get('oncmd', 'true'), - properties.get('offcmd', 'true'), - properties.get('statecmd', False), - properties.get(CONF_VALUE_TEMPLATE, False))) + device_config.get(CONF_FRIENDLY_NAME, device_name), + device_config.get(CONF_COMMAND_ON), + device_config.get(CONF_COMMAND_OFF), + device_config.get(CONF_COMMAND_STATE), + device_config.get(CONF_VALUE_TEMPLATE) + ) + ) - add_devices_callback(devices) + if not switches: + _LOGGER.error("No switches added") + return False + + add_devices(switches) # pylint: disable=too-many-instance-attributes diff --git a/homeassistant/components/switch/dlink.py b/homeassistant/components/switch/dlink.py index b65c521bad5..377826695a3 100644 --- a/homeassistant/components/switch/dlink.py +++ b/homeassistant/components/switch/dlink.py @@ -12,20 +12,27 @@ from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME) import homeassistant.helpers.config_validation as cv +from homeassistant.const import TEMP_CELSIUS REQUIREMENTS = ['https://github.com/LinuxChristian/pyW215/archive/' - 'v0.1.1.zip#pyW215==0.1.1'] + 'v0.3.4.zip#pyW215==0.3.4'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'D-link Smart Plug W215' DEFAULT_PASSWORD = '' DEFAULT_USERNAME = 'admin' +CONF_USE_LEGACY_PROTOCOL = 'use_legacy_protocol' + +ATTR_CURRENT_CONSUMPTION = 'Current Consumption' +ATTR_TOTAL_CONSUMPTION = 'Total Consumption' +ATTR_TEMPERATURE = 'Temperature' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, vol.Required(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_USE_LEGACY_PROTOCOL, default=False): cv.boolean, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) @@ -38,16 +45,22 @@ def setup_platform(hass, config, add_devices, discovery_info=None): host = config.get(CONF_HOST) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) + use_legacy_protocol = config.get(CONF_USE_LEGACY_PROTOCOL) name = config.get(CONF_NAME) - add_devices([SmartPlugSwitch(SmartPlug(host, password, username), name)]) + add_devices([SmartPlugSwitch(hass, SmartPlug(host, + password, + username, + use_legacy_protocol), + name)]) class SmartPlugSwitch(SwitchDevice): """Representation of a D-link Smart Plug switch.""" - def __init__(self, smartplug, name): + def __init__(self, hass, smartplug, name): """Initialize the switch.""" + self.units = hass.config.units self.smartplug = smartplug self._name = name @@ -56,6 +69,23 @@ class SmartPlugSwitch(SwitchDevice): """Return the name of the Smart Plug, if any.""" return self._name + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + ui_temp = self.units.temperature(int(self.smartplug.temperature), + TEMP_CELSIUS) + temperature = "{} {}".format(ui_temp, self.units.temperature_unit) + current_consumption = "{} W".format(self.smartplug.current_consumption) + total_consumption = "{} W".format(self.smartplug.total_consumption) + + attrs = { + ATTR_CURRENT_CONSUMPTION: current_consumption, + ATTR_TOTAL_CONSUMPTION: total_consumption, + ATTR_TEMPERATURE: temperature + } + + return attrs + @property def current_power_watt(self): """Return the current power usage in Watt.""" diff --git a/homeassistant/components/switch/edimax.py b/homeassistant/components/switch/edimax.py index 8240be692ba..41746f9a0ef 100644 --- a/homeassistant/components/switch/edimax.py +++ b/homeassistant/components/switch/edimax.py @@ -6,39 +6,40 @@ https://home-assistant.io/components/switch.edimax/ """ import logging -from homeassistant.components.switch import DOMAIN, SwitchDevice +import voluptuous as vol + +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME) -from homeassistant.helpers import validate_config +import homeassistant.helpers.config_validation as cv -# constants -DEFAULT_USERNAME = 'admin' -DEFAULT_PASSWORD = '1234' -DEVICE_DEFAULT_NAME = 'Edimax Smart Plug' REQUIREMENTS = ['https://github.com/rkabadi/pyedimax/archive/' '365301ce3ff26129a7910c501ead09ea625f3700.zip#pyedimax==0.1'] _LOGGER = logging.getLogger(__name__) +DEFAULT_NAME = 'Edimax Smart Plug' +DEFAULT_PASSWORD = '1234' +DEFAULT_USERNAME = 'admin' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, +}) + # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return Edimax Smart Plugs.""" from pyedimax.smartplug import SmartPlug - # pylint: disable=global-statement - # check for required values in configuration file - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_HOST]}, - _LOGGER): - return False - host = config.get(CONF_HOST) - auth = (config.get(CONF_USERNAME, DEFAULT_USERNAME), - config.get(CONF_PASSWORD, DEFAULT_PASSWORD)) - name = config.get(CONF_NAME, DEVICE_DEFAULT_NAME) + auth = (config.get(CONF_USERNAME), config.get(CONF_PASSWORD)) + name = config.get(CONF_NAME) - add_devices_callback([SmartPlugSwitch(SmartPlug(host, auth), name)]) + add_devices([SmartPlugSwitch(SmartPlug(host, auth), name)]) class SmartPlugSwitch(SwitchDevice): diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index 61a40315620..a0c982952e2 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components.light import is_on, turn_on from homeassistant.components.sun import next_setting, next_rising from homeassistant.components.switch import DOMAIN, SwitchDevice -from homeassistant.const import CONF_NAME, CONF_PLATFORM, EVENT_TIME_CHANGED +from homeassistant.const import CONF_NAME, CONF_PLATFORM from homeassistant.helpers.event import track_utc_time_change from homeassistant.util.color import color_temperature_to_rgb as temp_to_rgb from homeassistant.util.color import color_RGB_to_xy @@ -124,7 +124,7 @@ class FluxSwitch(SwitchDevice): self._stop_colortemp = stop_colortemp self._brightness = brightness self._mode = mode - self.tracker = None + self.unsub_tracker = None @property def name(self): @@ -139,15 +139,17 @@ class FluxSwitch(SwitchDevice): def turn_on(self, **kwargs): """Turn on flux.""" self._state = True - self.tracker = track_utc_time_change(self.hass, - self.flux_update, - second=[0, 30]) + self.unsub_tracker = track_utc_time_change(self.hass, self.flux_update, + second=[0, 30]) self.update_ha_state() def turn_off(self, **kwargs): """Turn off flux.""" + if self.unsub_tracker is not None: + self.unsub_tracker() + self.unsub_tracker = None + self._state = False - self.hass.bus.remove_listener(EVENT_TIME_CHANGED, self.tracker) self.update_ha_state() # pylint: disable=too-many-locals diff --git a/homeassistant/components/switch/hikvisioncam.py b/homeassistant/components/switch/hikvisioncam.py index 40874138e53..5a911ee3d74 100644 --- a/homeassistant/components/switch/hikvisioncam.py +++ b/homeassistant/components/switch/hikvisioncam.py @@ -6,31 +6,50 @@ https://home-assistant.io/components/switch.hikvision/ """ import logging +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME, STATE_OFF, STATE_ON) + CONF_NAME, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, STATE_OFF, + STATE_ON) from homeassistant.helpers.entity import ToggleEntity +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['hikvision==0.4'] _LOGGING = logging.getLogger(__name__) -REQUIREMENTS = ['hikvision==0.4'] + +DEFAULT_NAME = 'Hikvision Camera Motion Detection' +DEFAULT_PASSWORD = '12345' +DEFAULT_PORT = 80 +DEFAULT_USERNAME = 'admin' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, +}) + + # pylint: disable=too-many-arguments # pylint: disable=too-many-instance-attributes - - -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup Hikvision camera.""" import hikvision.api from hikvision.error import HikvisionError, MissingParamError - host = config.get(CONF_HOST, None) - port = config.get('port', "80") - name = config.get('name', "Hikvision Camera Motion Detection") - username = config.get(CONF_USERNAME, "admin") - password = config.get(CONF_PASSWORD, "12345") + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + name = config.get(CONF_NAME) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) try: hikvision_cam = hikvision.api.CreateDevice( - host, port=port, username=username, - password=password, is_https=False) + host, port=port, username=username, password=password, + is_https=False) except MissingParamError as param_err: _LOGGING.error("Missing required param: %s", param_err) return False @@ -38,9 +57,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): _LOGGING.error("Unable to connect: %s", conn_err) return False - add_devices_callback([ - HikvisionMotionSwitch(name, hikvision_cam) - ]) + add_devices([HikvisionMotionSwitch(name, hikvision_cam)]) class HikvisionMotionSwitch(ToggleEntity): @@ -85,6 +102,6 @@ class HikvisionMotionSwitch(ToggleEntity): def update(self): """Update Motion Detection state.""" enabled = self._hikvision_cam.is_motion_detection_enabled() - _LOGGING.info('enabled: %s', enabled) + _LOGGING.info("enabled: %s", enabled) self._state = STATE_ON if enabled else STATE_OFF diff --git a/homeassistant/components/switch/homematic.py b/homeassistant/components/switch/homematic.py index e9f103b95fa..e13027780c6 100644 --- a/homeassistant/components/switch/homematic.py +++ b/homeassistant/components/switch/homematic.py @@ -19,9 +19,11 @@ def setup_platform(hass, config, add_callback_devices, discovery_info=None): if discovery_info is None: return - return homematic.setup_hmdevice_discovery_helper(HMSwitch, - discovery_info, - add_callback_devices) + return homematic.setup_hmdevice_discovery_helper( + HMSwitch, + discovery_info, + add_callback_devices + ) class HMSwitch(homematic.HMDevice, SwitchDevice): @@ -56,47 +58,12 @@ class HMSwitch(homematic.HMDevice, SwitchDevice): if self.available: self._hmdevice.off(self._channel) - def _check_hm_to_ha_object(self): - """Check if possible to use the Homematic object as this HA type.""" - from pyhomematic.devicetypes.actors import Dimmer, Switch - - # Check compatibility from HMDevice - if not super()._check_hm_to_ha_object(): - return False - - # Check if the Homematic device is correct for this HA device - if isinstance(self._hmdevice, Switch): - return True - if isinstance(self._hmdevice, Dimmer): - return True - - _LOGGER.critical("This %s can't be use as switch", self._name) - return False - def _init_data_struct(self): """Generate a data dict (self._data) from the Homematic metadata.""" - from pyhomematic.devicetypes.actors import Dimmer,\ - Switch, SwitchPowermeter - - super()._init_data_struct() - # Use STATE - if isinstance(self._hmdevice, Switch): - self._state = "STATE" - - # Use LEVEL - if isinstance(self._hmdevice, Dimmer): - self._state = "LEVEL" + self._state = "STATE" + self._data.update({self._state: STATE_UNKNOWN}) # Need sensor values for SwitchPowermeter - if isinstance(self._hmdevice, SwitchPowermeter): - for node in self._hmdevice.SENSORNODE: - self._data.update({node: STATE_UNKNOWN}) - - # Add state to data dict - if self._state: - _LOGGER.debug("%s init data dict with main node '%s'", self._name, - self._state) - self._data.update({self._state: STATE_UNKNOWN}) - else: - _LOGGER.critical("Can't correctly init light %s.", self._name) + for node in self._hmdevice.SENSORNODE: + self._data.update({node: STATE_UNKNOWN}) diff --git a/homeassistant/components/switch/mfi.py b/homeassistant/components/switch/mfi.py index cca59111495..48e4741e770 100644 --- a/homeassistant/components/switch/mfi.py +++ b/homeassistant/components/switch/mfi.py @@ -7,43 +7,49 @@ https://home-assistant.io/components/switch.mfi/ import logging import requests +import voluptuous as vol -from homeassistant.components.switch import DOMAIN, SwitchDevice -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import validate_config +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_HOST, CONF_PORT, CONF_PASSWORD, CONF_USERNAME, CONF_SSL, + CONF_VERIFY_SSL) +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['mficlient==0.3.0'] _LOGGER = logging.getLogger(__name__) +DEFAULT_PORT = 6443 +DEFAULT_SSL = True +DEFAULT_VERIFY_SSL = True + SWITCH_MODELS = [ 'Outlet', 'Output 5v', 'Output 12v', 'Output 24v', ] -CONF_TLS = 'use_tls' -CONF_VERIFY_TLS = 'verify_tls' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, +}) # pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Setup mFi sensors.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: ['host', - CONF_USERNAME, - CONF_PASSWORD]}, - _LOGGER): - _LOGGER.error('A host, username, and password are required') - return False - - host = config.get('host') - username = config.get('username') - password = config.get('password') - use_tls = bool(config.get(CONF_TLS, True)) - verify_tls = bool(config.get(CONF_VERIFY_TLS, True)) - default_port = use_tls and 6443 or 6080 - port = int(config.get('port', default_port)) + host = config.get(CONF_HOST) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + use_tls = config.get(CONF_SSL) + verify_tls = config.get(CONF_VERIFY_SSL) + default_port = use_tls and DEFAULT_PORT or 6080 + port = int(config.get(CONF_PORT, default_port)) from mficlient.client import FailedToLogin, MFiClient diff --git a/homeassistant/components/switch/modbus.py b/homeassistant/components/switch/modbus.py index 971947a6ed3..2ae0c74991d 100644 --- a/homeassistant/components/switch/modbus.py +++ b/homeassistant/components/switch/modbus.py @@ -90,12 +90,10 @@ class ModbusSwitch(ToggleEntity): self.update() if self._coil: - modbus.NETWORK.write_coil(self.register, True) + modbus.HUB.write_coil(self.slave, self.register, True) else: val = self.register_value | (0x0001 << self.bit) - modbus.NETWORK.write_register(unit=self.slave, - address=self.register, - value=val) + modbus.HUB.write_register(self.slave, self.register, val) def turn_off(self, **kwargs): """Set switch off.""" @@ -103,23 +101,22 @@ class ModbusSwitch(ToggleEntity): self.update() if self._coil: - modbus.NETWORK.write_coil(self.register, False) + modbus.HUB.write_coil(self.slave, self.register, False) else: val = self.register_value & ~(0x0001 << self.bit) - modbus.NETWORK.write_register(unit=self.slave, - address=self.register, - value=val) + modbus.HUB.write_register(self.slave, self.register, val) def update(self): """Update the state of the switch.""" if self._coil: - result = modbus.NETWORK.read_coils(self.register, 1) + result = modbus.HUB.read_coils(self.slave, self.register, 1) self.register_value = result.bits[0] self._is_on = self.register_value else: - result = modbus.NETWORK.read_holding_registers( - unit=self.slave, address=self.register, - count=1) + result = modbus.HUB.read_holding_registers( + self.slave, + self.register, + 1) val = 0 for i, res in enumerate(result.registers): val += res * (2**(i*16)) diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index 2a2b2aed547..d17ea82cd32 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -8,24 +8,23 @@ import logging import voluptuous as vol -import homeassistant.components.mqtt as mqtt -from homeassistant.components.switch import SwitchDevice -from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE from homeassistant.components.mqtt import ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) -import homeassistant.helpers.config_validation as cv +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import ( + CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_OFF, + CONF_PAYLOAD_ON) from homeassistant.helpers import template +import homeassistant.components.mqtt as mqtt +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['mqtt'] -CONF_PAYLOAD_ON = 'payload_on' -CONF_PAYLOAD_OFF = 'payload_off' - -DEFAULT_NAME = "MQTT Switch" -DEFAULT_PAYLOAD_ON = "ON" -DEFAULT_PAYLOAD_OFF = "OFF" +DEFAULT_NAME = 'MQTT Switch' +DEFAULT_PAYLOAD_ON = 'ON' +DEFAULT_PAYLOAD_OFF = 'OFF' DEFAULT_OPTIMISTIC = False PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ @@ -37,19 +36,20 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """Add MQTT switch.""" - add_devices_callback([MqttSwitch( +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the MQTT switch.""" + add_devices([MqttSwitch( hass, - config[CONF_NAME], + config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), - config[CONF_COMMAND_TOPIC], - config[CONF_QOS], - config[CONF_RETAIN], - config[CONF_PAYLOAD_ON], - config[CONF_PAYLOAD_OFF], - config[CONF_OPTIMISTIC], - config.get(CONF_VALUE_TEMPLATE))]) + config.get(CONF_COMMAND_TOPIC), + config.get(CONF_QOS), + config.get(CONF_RETAIN), + config.get(CONF_PAYLOAD_ON), + config.get(CONF_PAYLOAD_OFF), + config.get(CONF_OPTIMISTIC), + config.get(CONF_VALUE_TEMPLATE) + )]) # pylint: disable=too-many-arguments, too-many-instance-attributes @@ -86,8 +86,8 @@ class MqttSwitch(SwitchDevice): # Force into optimistic mode. self._optimistic = True else: - mqtt.subscribe(hass, self._state_topic, message_received, - self._qos) + mqtt.subscribe( + hass, self._state_topic, message_received, self._qos) @property def should_poll(self): diff --git a/homeassistant/components/switch/netio.py b/homeassistant/components/switch/netio.py index b33e71df49d..7d30990e823 100644 --- a/homeassistant/components/switch/netio.py +++ b/homeassistant/components/switch/netio.py @@ -1,63 +1,9 @@ """ -Netio switch component. - -The Netio platform allows you to control your [Netio] -(http://www.netio-products.com/en/overview/) Netio4, Netio4 All and Netio 230B. -These are smart outlets controllable through ethernet and/or WiFi that reports -consumptions (Netio4all). - -To use these devices in your installation, add the following to your -configuration.yaml file: -``` -switch: - - platform: netio - host: netio-living - outlets: - 1: "AppleTV" - 2: "Htpc" - 3: "Lampe Gauche" - 4: "Lampe Droite" - - platform: netio - host: 192.168.1.43 - port: 1234 - username: user - password: pwd - outlets: - 1: "Nothing..." - 4: "Lampe du fer" -``` - -To get pushed updates from the netio devices, one can add this lua code in the -device interface as an action triggered on "Netio" "System variables updated" -with an 'Always' schedule: - -`` --- this will send socket and consumption status updates via CGI --- to given address. Associate with 'System variables update' event --- to get consumption updates when they show up - -local address='ha:8123' -local path = '/api/netio/' - - -local output = {} -for i = 1, 4 do for _, what in pairs({'state', 'consumption', - 'cumulatedConsumption', 'consumptionStart'}) do - local varname = string.format('output%d_%s', i, what) - table.insert(output, - varname..'='..tostring(devices.system[varname]):gsub(" ","|")) -end end - -local qs = table.concat(output, '&') -local url = string.format('http://%s%s?%s', address, path, qs) -devices.system.CustomCGI{url=url} -``` - +The Netio switch component. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.netio/ """ - import logging from collections import namedtuple from datetime import timedelta diff --git a/homeassistant/components/switch/orvibo.py b/homeassistant/components/switch/orvibo.py index b2b8ed41abe..0ce1426dd1f 100644 --- a/homeassistant/components/switch/orvibo.py +++ b/homeassistant/components/switch/orvibo.py @@ -6,40 +6,60 @@ https://home-assistant.io/components/switch.orvibo/ """ import logging -from homeassistant.components.switch import SwitchDevice +import voluptuous as vol + +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_SWITCHES, CONF_MAC, CONF_DISCOVERY) +import homeassistant.helpers.config_validation as cv -DEFAULT_NAME = "Orvibo S20 Switch" REQUIREMENTS = ['orvibo==1.1.1'] + _LOGGER = logging.getLogger(__name__) +DEFAULT_NAME = 'Orvibo S20 Switch' +DEFAULT_DISCOVERY = True + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SWITCHES, default=[]): + vol.All(cv.ensure_list, [{ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_MAC): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string + }]), + vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): cv.boolean, +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """Find and return S20 switches.""" - from orvibo.s20 import S20, S20Exception + """Setup S20 switches.""" + from orvibo.s20 import discover, S20, S20Exception + switch_data = {} switches = [] - switch_conf = config.get('switches', [config]) + switch_conf = config.get(CONF_SWITCHES, [config]) + + if config.get(CONF_DISCOVERY): + _LOGGER.info("Discovering S20 switches ...") + switch_data.update(discover()) for switch in switch_conf: - if switch.get('host') is None: - _LOGGER.error("Missing required variable: host") - continue - host = switch.get('host') - mac = switch.get('mac') + switch_data[switch.get(CONF_HOST)] = switch + + for host, data in switch_data.items(): try: - switches.append(S20Switch(switch.get('name', DEFAULT_NAME), - S20(host, mac=mac))) + switches.append(S20Switch(data.get(CONF_NAME), + S20(host, mac=data.get(CONF_MAC)))) _LOGGER.info("Initialized S20 at %s", host) except S20Exception: - _LOGGER.exception("S20 at %s couldn't be initialized", - host) + _LOGGER.error("S20 at %s couldn't be initialized", host) add_devices_callback(switches) class S20Switch(SwitchDevice): - """Representsation of an S20 switch.""" + """Representation of an S20 switch.""" def __init__(self, name, s20): """Initialize the S20 device.""" diff --git a/homeassistant/components/switch/pulseaudio_loopback.py b/homeassistant/components/switch/pulseaudio_loopback.py index b9175bee9b7..c9ee19aa0e3 100644 --- a/homeassistant/components/switch/pulseaudio_loopback.py +++ b/homeassistant/components/switch/pulseaudio_loopback.py @@ -9,69 +9,75 @@ import re import socket from datetime import timedelta +import voluptuous as vol + import homeassistant.util as util -from homeassistant.components.switch import SwitchDevice -from homeassistant.util import convert +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_PORT) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) _PULSEAUDIO_SERVERS = {} -DEFAULT_NAME = "paloopback" -DEFAULT_HOST = "localhost" -DEFAULT_PORT = 4712 -DEFAULT_BUFFER_SIZE = 1024 -DEFAULT_TCP_TIMEOUT = 3 -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) +CONF_BUFFER_SIZE = 'buffer_size' +CONF_SINK_NAME = 'sink_name' +CONF_SOURCE_NAME = 'source_name' +CONF_TCP_TIMEOUT = 'tcp_timeout' -LOAD_CMD = "load-module module-loopback sink={0} source={1}" -UNLOAD_CMD = "unload-module {0}" -MOD_REGEX = r"index: ([0-9]+)\s+name: " \ - r"\s+argument: (?=<.*sink={0}.*>)(?=<.*source={1}.*>)" +DEFAULT_BUFFER_SIZE = 1024 +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'paloopback' +DEFAULT_PORT = 4712 +DEFAULT_TCP_TIMEOUT = 3 IGNORED_SWITCH_WARN = "Switch is already in the desired state. Ignoring." +LOAD_CMD = "load-module module-loopback sink={0} source={1}" + +MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) +MOD_REGEX = r"index: ([0-9]+)\s+name: " \ + r"\s+argument: (?=<.*sink={0}.*>)(?=<.*source={1}.*>)" + +UNLOAD_CMD = "unload-module {0}" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SINK_NAME): cv.string, + vol.Required(CONF_SOURCE_NAME): cv.string, + vol.Optional(CONF_BUFFER_SIZE, default=DEFAULT_BUFFER_SIZE): + cv.positive_int, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_TCP_TIMEOUT, default=DEFAULT_TCP_TIMEOUT): + cv.positive_int, +}) + # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Read in all of our configuration, and initialize the loopback switch.""" - if config.get('sink_name') is None: - _LOGGER.error("Missing required variable: sink_name") - return False - - if config.get('source_name') is None: - _LOGGER.error("Missing required variable: source_name") - return False - - name = convert(config.get('name'), str, DEFAULT_NAME) - sink_name = config.get('sink_name') - source_name = config.get('source_name') - host = convert(config.get('host'), str, DEFAULT_HOST) - port = convert(config.get('port'), int, DEFAULT_PORT) - buffer_size = convert(config.get('buffer_size'), int, DEFAULT_BUFFER_SIZE) - tcp_timeout = convert(config.get('tcp_timeout'), int, DEFAULT_TCP_TIMEOUT) + name = config.get(CONF_NAME) + sink_name = config.get(CONF_SINK_NAME) + source_name = config.get(CONF_SOURCE_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + buffer_size = config.get(CONF_BUFFER_SIZE) + tcp_timeout = config.get(CONF_TCP_TIMEOUT) server_id = str.format("{0}:{1}", host, port) if server_id in _PULSEAUDIO_SERVERS: server = _PULSEAUDIO_SERVERS[server_id] - else: server = PAServer(host, port, buffer_size, tcp_timeout) - _PULSEAUDIO_SERVERS[server_id] = server - add_devices_callback([PALoopbackSwitch( - hass, - name, - server, - sink_name, - source_name - )]) + add_devices([PALoopbackSwitch(hass, name, server, sink_name, source_name)]) class PAServer(): - """Represents a pulseaudio server.""" + """Representation of a Pulseaudio server.""" _current_module_state = "" @@ -88,11 +94,11 @@ class PAServer(): sock.settimeout(self._tcp_timeout) try: sock.connect((self._pa_host, self._pa_port)) - _LOGGER.info("Calling pulseaudio:" + cmd) + _LOGGER.info("Calling pulseaudio: %s", cmd) sock.send((cmd + "\n").encode("utf-8")) if response_expected: return_data = self._get_full_response(sock) - _LOGGER.debug("Data received from pulseaudio: " + return_data) + _LOGGER.debug("Data received from pulseaudio: %s", return_data) else: return_data = "" finally: @@ -103,11 +109,11 @@ class PAServer(): """Helper method to get the full response back from pulseaudio.""" result = "" rcv_buffer = sock.recv(self._buffer_size) - result += rcv_buffer.decode("utf-8") + result += rcv_buffer.decode('utf-8') while len(rcv_buffer) == self._buffer_size: rcv_buffer = sock.recv(self._buffer_size) - result += rcv_buffer.decode("utf-8") + result += rcv_buffer.decode('utf-8') return result @@ -118,10 +124,7 @@ class PAServer(): def turn_on(self, sink_name, source_name): """Send a command to pulseaudio to turn on the loopback.""" - self._send_command(str.format(LOAD_CMD, - sink_name, - source_name), - False) + self._send_command(str.format(LOAD_CMD, sink_name, source_name), False) def turn_off(self, module_idx): """Send a command to pulseaudio to turn off the loopback.""" @@ -129,8 +132,7 @@ class PAServer(): def get_module_idx(self, sink_name, source_name): """For a sink/source, return it's module id in our cache, if found.""" - result = re.search(str.format(MOD_REGEX, - re.escape(sink_name), + result = re.search(str.format(MOD_REGEX, re.escape(sink_name), re.escape(source_name)), self._current_module_state) if result and result.group(1).isdigit(): @@ -141,11 +143,10 @@ class PAServer(): # pylint: disable=too-many-arguments class PALoopbackSwitch(SwitchDevice): - """Represents the presence or absence of a pa loopback module.""" + """Representation the presence or absence of a PA loopback module.""" - def __init__(self, hass, name, pa_server, - sink_name, source_name): - """Initialize the switch.""" + def __init__(self, hass, name, pa_server, sink_name, source_name): + """Initialize the Pulseaudio switch.""" self._module_idx = -1 self._hass = hass self._name = name @@ -168,8 +169,8 @@ class PALoopbackSwitch(SwitchDevice): if not self.is_on: self._pa_svr.turn_on(self._sink_name, self._source_name) self._pa_svr.update_module_state(no_throttle=True) - self._module_idx = self._pa_svr.get_module_idx(self._sink_name, - self._source_name) + self._module_idx = self._pa_svr.get_module_idx( + self._sink_name, self._source_name) self.update_ha_state() else: _LOGGER.warning(IGNORED_SWITCH_WARN) @@ -179,8 +180,8 @@ class PALoopbackSwitch(SwitchDevice): if self.is_on: self._pa_svr.turn_off(self._module_idx) self._pa_svr.update_module_state(no_throttle=True) - self._module_idx = self._pa_svr.get_module_idx(self._sink_name, - self._source_name) + self._module_idx = self._pa_svr.get_module_idx( + self._sink_name, self._source_name) self.update_ha_state() else: _LOGGER.warning(IGNORED_SWITCH_WARN) @@ -188,5 +189,5 @@ class PALoopbackSwitch(SwitchDevice): def update(self): """Refresh state in case an alternate process modified this data.""" self._pa_svr.update_module_state() - self._module_idx = self._pa_svr.get_module_idx(self._sink_name, - self._source_name) + self._module_idx = self._pa_svr.get_module_idx( + self._sink_name, self._source_name) diff --git a/homeassistant/components/switch/template.py b/homeassistant/components/switch/template.py index 6778315843e..2b043c110a4 100644 --- a/homeassistant/components/switch/template.py +++ b/homeassistant/components/switch/template.py @@ -5,28 +5,27 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.template/ """ import logging + import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.switch import ( ENTITY_ID_FORMAT, SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( ATTR_FRIENDLY_NAME, CONF_VALUE_TEMPLATE, STATE_OFF, STATE_ON, - ATTR_ENTITY_ID, MATCH_ALL) + ATTR_ENTITY_ID, MATCH_ALL, CONF_SWITCHES) from homeassistant.exceptions import TemplateError -from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers.script import Script from homeassistant.helpers import template +from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.event import track_state_change - -CONF_SWITCHES = 'switches' - -ON_ACTION = 'turn_on' -OFF_ACTION = 'turn_off' +from homeassistant.helpers.script import Script +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) _VALID_STATES = [STATE_ON, STATE_OFF, 'true', 'false'] +ON_ACTION = 'turn_on' +OFF_ACTION = 'turn_off' + SWITCH_SCHEMA = vol.Schema({ vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Required(ON_ACTION): cv.SCRIPT_SCHEMA, @@ -91,8 +90,7 @@ class SwitchTemplate(SwitchDevice): """Called when the target device changes state.""" self.update_ha_state(True) - track_state_change(hass, entity_ids, - template_switch_state_listener) + track_state_change(hass, entity_ids, template_switch_state_listener) @property def name(self): diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py index a1de3621b9a..ddb10e74c37 100644 --- a/homeassistant/components/switch/tplink.py +++ b/homeassistant/components/switch/tplink.py @@ -4,25 +4,35 @@ Support for TPLink HS100/HS110 smart switch. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.tplink/ """ -from homeassistant.components.switch import SwitchDevice -from homeassistant.const import ( - CONF_HOST, CONF_NAME) +import logging + +import voluptuous as vol + +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.const import (CONF_HOST, CONF_NAME) +import homeassistant.helpers.config_validation as cv -# constants -DEVICE_DEFAULT_NAME = 'HS100' REQUIREMENTS = ['https://github.com/gadgetreactor/pyHS100/archive/' 'master.zip#pyHS100==0.1.2'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'TPLink Switch HS100' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the TPLink switch platform.""" from pyHS100.pyHS100 import SmartPlug host = config.get(CONF_HOST) - name = config.get(CONF_NAME, DEVICE_DEFAULT_NAME) + name = config.get(CONF_NAME) - add_devices_callback([SmartPlugSwitch(SmartPlug(host), - name)]) + add_devices([SmartPlugSwitch(SmartPlug(host), name)]) class SmartPlugSwitch(SwitchDevice): diff --git a/homeassistant/components/switch/verisure.py b/homeassistant/components/switch/verisure.py index 1bd0a46fb78..d7974335811 100644 --- a/homeassistant/components/switch/verisure.py +++ b/homeassistant/components/switch/verisure.py @@ -2,11 +2,12 @@ Support for Verisure Smartplugs. For more details about this platform, please refer to the documentation at -documentation at https://home-assistant.io/components/verisure/ +https://home-assistant.io/components/switch.verisure/ """ import logging from homeassistant.components.verisure import HUB as hub +from homeassistant.components.verisure import CONF_SMARTPLUGS from homeassistant.components.switch import SwitchDevice _LOGGER = logging.getLogger(__name__) @@ -14,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Verisure switch platform.""" - if not int(hub.config.get('smartplugs', '1')): + if not int(hub.config.get(CONF_SMARTPLUGS, 1)): return False hub.update_smartplugs() diff --git a/homeassistant/components/switch/zigbee.py b/homeassistant/components/switch/zigbee.py index 4588be139a2..7a58b0867c1 100644 --- a/homeassistant/components/switch/zigbee.py +++ b/homeassistant/components/switch/zigbee.py @@ -4,18 +4,29 @@ Contains functionality to use a ZigBee device as a switch. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.zigbee/ """ +import voluptuous as vol + from homeassistant.components.switch import SwitchDevice from homeassistant.components.zigbee import ( - ZigBeeDigitalOut, ZigBeeDigitalOutConfig) + ZigBeeDigitalOut, ZigBeeDigitalOutConfig, PLATFORM_SCHEMA) -DEPENDENCIES = ["zigbee"] +DEPENDENCIES = ['zigbee'] + +CONF_ON_STATE = 'on_state' + +DEFAULT_ON_STATE = 'high' +DEPENDENCIES = ['zigbee'] + +STATES = ['high', 'low'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_ON_STATE): vol.In(STATES), +}) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the ZigBee switch platform.""" - add_entities([ - ZigBeeSwitch(hass, ZigBeeDigitalOutConfig(config)) - ]) + add_devices([ZigBeeSwitch(hass, ZigBeeDigitalOutConfig(config))]) class ZigBeeSwitch(ZigBeeDigitalOut, SwitchDevice): diff --git a/homeassistant/components/thermostat/homematic.py b/homeassistant/components/thermostat/homematic.py deleted file mode 100644 index 73901ab61df..00000000000 --- a/homeassistant/components/thermostat/homematic.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -Support for Homematic thermostats. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/thermostat.homematic/ -""" -import logging -import homeassistant.components.homematic as homematic -from homeassistant.components.thermostat import ThermostatDevice -from homeassistant.util.temperature import convert -from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN - -DEPENDENCIES = ['homematic'] - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_callback_devices, discovery_info=None): - """Setup the Homematic thermostat platform.""" - if discovery_info is None: - return - - return homematic.setup_hmdevice_discovery_helper(HMThermostat, - discovery_info, - add_callback_devices) - - -# pylint: disable=abstract-method -class HMThermostat(homematic.HMDevice, ThermostatDevice): - """Representation of a Homematic thermostat.""" - - @property - def unit_of_measurement(self): - """Return the unit of measurement that is used.""" - return TEMP_CELSIUS - - @property - def current_temperature(self): - """Return the current temperature.""" - if not self.available: - return None - return self._data["ACTUAL_TEMPERATURE"] - - @property - def target_temperature(self): - """Return the target temperature.""" - if not self.available: - return None - return self._data["SET_TEMPERATURE"] - - def set_temperature(self, temperature): - """Set new target temperature.""" - if not self.available: - return None - self._hmdevice.set_temperature(temperature) - - @property - def min_temp(self): - """Return the minimum temperature - 4.5 means off.""" - return convert(4.5, TEMP_CELSIUS, self.unit_of_measurement) - - @property - def max_temp(self): - """Return the maximum temperature - 30.5 means on.""" - return convert(30.5, TEMP_CELSIUS, self.unit_of_measurement) - - def _check_hm_to_ha_object(self): - """Check if possible to use the Homematic object as this HA type.""" - from pyhomematic.devicetypes.thermostats import HMThermostat\ - as pyHMThermostat - - # Check compatibility from HMDevice - if not super()._check_hm_to_ha_object(): - return False - - # Check if the Homematic device correct for this HA device - if isinstance(self._hmdevice, pyHMThermostat): - return True - - _LOGGER.critical("This %s can't be use as thermostat", self._name) - return False - - def _init_data_struct(self): - """Generate a data dict (self._data) from the Homematic metadata.""" - super()._init_data_struct() - - # Add state to data dict - self._data.update({"CONTROL_MODE": STATE_UNKNOWN, - "SET_TEMPERATURE": STATE_UNKNOWN, - "ACTUAL_TEMPERATURE": STATE_UNKNOWN}) diff --git a/homeassistant/components/verisure.py b/homeassistant/components/verisure.py index 1231a4128fa..8634184fe57 100644 --- a/homeassistant/components/verisure.py +++ b/homeassistant/components/verisure.py @@ -9,26 +9,46 @@ import threading import time from datetime import timedelta -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import validate_config, discovery -from homeassistant.util import Throttle +import voluptuous as vol -DOMAIN = "verisure" +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import discovery +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['vsure==0.10.2'] _LOGGER = logging.getLogger(__name__) +CONF_ALARM = 'alarm' +CONF_CODE_DIGITS = 'code_digits' +CONF_HYDROMETERS = 'hygrometers' +CONF_LOCKS = 'locks' +CONF_MOUSE = 'mouse' +CONF_SMARTPLUGS = 'smartplugs' +CONF_THERMOMETERS = 'thermometers' + +DOMAIN = 'verisure' + HUB = None +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_ALARM, default=True): cv.boolean, + vol.Optional(CONF_CODE_DIGITS, default=4): cv.positive_int, + vol.Optional(CONF_HYDROMETERS, default=True): cv.boolean, + vol.Optional(CONF_LOCKS, default=True): cv.boolean, + vol.Optional(CONF_MOUSE, default=True): cv.boolean, + vol.Optional(CONF_SMARTPLUGS, default=True): cv.boolean, + vol.Optional(CONF_THERMOMETERS, default=True): cv.boolean, + }), +}, extra=vol.ALLOW_EXTRA) + def setup(hass, config): """Setup the Verisure component.""" - if not validate_config(config, - {DOMAIN: [CONF_USERNAME, CONF_PASSWORD]}, - _LOGGER): - return False - import verisure global HUB HUB = VerisureHub(config[DOMAIN], verisure) diff --git a/homeassistant/components/weblink.py b/homeassistant/components/weblink.py index 08ba7eb036e..df9dcef9ac1 100644 --- a/homeassistant/components/weblink.py +++ b/homeassistant/components/weblink.py @@ -6,30 +6,39 @@ https://home-assistant.io/components/weblink/ """ import logging +import voluptuous as vol + +from homeassistant.const import (CONF_NAME, CONF_ICON, CONF_URL) from homeassistant.helpers.entity import Entity from homeassistant.util import slugify - -DOMAIN = "weblink" -DEPENDENCIES = [] - -ATTR_NAME = 'name' -ATTR_URL = 'url' -ATTR_ICON = 'icon' +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +CONF_ENTITIES = 'entities' + +DOMAIN = 'weblink' + +ENTITIES_SCHEMA = vol.Schema({ + vol.Required(CONF_URL): cv.url, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_ICON): cv.icon, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_ENTITIES): [ENTITIES_SCHEMA], + }), +}, extra=vol.ALLOW_EXTRA) + def setup(hass, config): """Setup weblink component.""" links = config.get(DOMAIN) - for link in links.get('entities'): - if ATTR_NAME not in link or ATTR_URL not in link: - _LOGGER.error("You need to set both %s and %s to add a %s", - ATTR_NAME, ATTR_URL, DOMAIN) - continue - Link(hass, link.get(ATTR_NAME), link.get(ATTR_URL), - link.get(ATTR_ICON)) + for link in links.get(CONF_ENTITIES): + Link(hass, link.get(CONF_NAME), link.get(CONF_URL), + link.get(CONF_URL)) return True diff --git a/homeassistant/components/wink.py b/homeassistant/components/wink.py index 96f40c8d1f7..6d6e09b1918 100644 --- a/homeassistant/components/wink.py +++ b/homeassistant/components/wink.py @@ -41,8 +41,8 @@ def setup(hass, config): ('binary_sensor', pywink.get_sensors), ('sensor', lambda: pywink.get_sensors or pywink.get_eggtrays), ('lock', pywink.get_locks), - ('rollershutter', pywink.get_shades), - ('garage_door', pywink.get_garage_doors)): + ('cover', pywink.get_shades), + ('cover', pywink.get_garage_doors)): if func_exists(): discovery.load_platform(hass, component_name, DOMAIN, {}, config) diff --git a/homeassistant/components/zigbee.py b/homeassistant/components/zigbee.py index 84770390ad9..4b4da350199 100644 --- a/homeassistant/components/zigbee.py +++ b/homeassistant/components/zigbee.py @@ -9,19 +9,26 @@ import pickle from binascii import hexlify, unhexlify from base64 import b64encode, b64decode -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +import voluptuous as vol + +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, CONF_DEVICE, CONF_NAME, CONF_PIN) from homeassistant.core import JobPriority from homeassistant.helpers.entity import Entity +from homeassistant.helpers import config_validation as cv -DOMAIN = "zigbee" -REQUIREMENTS = ("xbee-helper==0.0.7",) +REQUIREMENTS = ['xbee-helper==0.0.7'] -EVENT_ZIGBEE_FRAME_RECEIVED = "zigbee_frame_received" +_LOGGER = logging.getLogger(__name__) -CONF_DEVICE = "device" -CONF_BAUD = "baud" +DOMAIN = 'zigbee' -DEFAULT_DEVICE = "/dev/ttyUSB0" +EVENT_ZIGBEE_FRAME_RECEIVED = 'zigbee_frame_received' + +CONF_ADDRESS = 'address' +CONF_BAUD = 'baud' + +DEFAULT_DEVICE = '/dev/ttyUSB0' DEFAULT_BAUD = 9600 DEFAULT_ADC_MAX_VOLTS = 1.2 @@ -35,11 +42,22 @@ CONVERT_ADC = None ZIGBEE_EXCEPTION = None ZIGBEE_TX_FAILURE = None -ATTR_FRAME = "frame" +ATTR_FRAME = 'frame' DEVICE = None -_LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_BAUD, default=DEFAULT_BAUD): cv.string, + vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_PIN): cv.positive_int, + vol.Optional(CONF_ADDRESS): cv.string, +}, extra=vol.ALLOW_EXTRA) def setup(hass, config): @@ -101,9 +119,9 @@ def close_serial_port(*args): def frame_is_relevant(entity, frame): """Test whether the frame is relevant to the entity.""" - if frame.get("source_addr_long") != entity.config.address: + if frame.get('source_addr_long') != entity.config.address: return False - if "samples" not in frame: + if 'samples' not in frame: return False return True @@ -279,7 +297,7 @@ class ZigBeeDigitalIn(Entity): """ if not frame_is_relevant(self, frame): return - sample = frame["samples"].pop() + sample = frame['samples'].pop() pin_name = DIGITAL_PINS[self._config.pin] if pin_name not in sample: # Doesn't contain information about our pin @@ -402,7 +420,7 @@ class ZigBeeAnalogIn(Entity): """ if not frame_is_relevant(self, frame): return - sample = frame["samples"].pop() + sample = frame['samples'].pop() pin_name = ANALOG_PINS[self._config.pin] if pin_name not in sample: # Doesn't contain information about our pin diff --git a/homeassistant/components/zone.py b/homeassistant/components/zone.py index db57b387c9f..a7841578e2b 100644 --- a/homeassistant/components/zone.py +++ b/homeassistant/components/zone.py @@ -27,6 +27,9 @@ ATTR_PASSIVE = 'passive' DEFAULT_PASSIVE = False ICON_HOME = 'mdi:home' +ICON_IMPORT = 'mdi:import' + +_LOGGER = logging.getLogger(__name__) def active_zone(hass, latitude, longitude, radius=0): @@ -71,7 +74,6 @@ def in_zone(zone, latitude, longitude, radius=0): def setup(hass, config): """Setup zone.""" entities = set() - for key in extract_domain_configs(config, DOMAIN): entries = config[key] if not isinstance(entries, list): @@ -90,26 +92,48 @@ def setup(hass, config): 'Each zone needs a latitude and longitude.') continue - zone = Zone(hass, name, latitude, longitude, radius, icon, passive) - zone.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, - entities) - zone.update_ha_state() + zone = Zone(hass, name, latitude, longitude, radius, + icon, passive, False) + add_zone(hass, name, zone, entities) entities.add(zone.entity_id) if ENTITY_ID_HOME not in entities: - zone = Zone(hass, hass.config.location_name, hass.config.latitude, - hass.config.longitude, DEFAULT_RADIUS, ICON_HOME, False) + zone = Zone(hass, hass.config.location_name, + hass.config.latitude, hass.config.longitude, + DEFAULT_RADIUS, ICON_HOME, False, False) + add_zone(hass, hass.config.location_name, zone, entities) zone.entity_id = ENTITY_ID_HOME zone.update_ha_state() return True +# Add a zone to the existing set +def add_zone(hass, name, zone, entities=None): + """Add a zone from other components.""" + _LOGGER.info("Adding new zone %s", name) + if entities is None: + _entities = set() + else: + _entities = entities + zone.entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, + _entities) + zone_exists = hass.states.get(zone.entity_id) + if zone_exists is None: + zone.update_ha_state() + _entities.add(zone.entity_id) + return zone + else: + _LOGGER.info("Zone already exists") + return zone_exists + + class Zone(Entity): """Representation of a Zone.""" # pylint: disable=too-many-arguments, too-many-instance-attributes - def __init__(self, hass, name, latitude, longitude, radius, icon, passive): + def __init__(self, hass, name, latitude, longitude, radius, icon, passive, + imported): """Initialize the zone.""" self.hass = hass self._name = name @@ -118,6 +142,7 @@ class Zone(Entity): self._radius = radius self._icon = icon self._passive = passive + self._imported = imported @property def name(self): diff --git a/homeassistant/const.py b/homeassistant/const.py index f6f0cd7855d..eb8b65df998 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,9 +1,43 @@ # coding: utf-8 """Constants used by Home Assistant components.""" - -__version__ = '0.27.2' +MAJOR_VERSION = 0 +MINOR_VERSION = 28 +PATCH_VERSION = '0' +__short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) +__version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4) +PROJECT_NAME = 'Home Assistant' +PROJECT_PACKAGE_NAME = 'homeassistant' +PROJECT_LICENSE = 'MIT License' +PROJECT_AUTHOR = 'The Home Assistant Authors' +PROJECT_COPYRIGHT = ' 2016, {}'.format(PROJECT_AUTHOR) +PROJECT_URL = 'https://home-assistant.io/' +PROJECT_EMAIL = 'hello@home-assistant.io' +PROJECT_DESCRIPTION = ('Open-source home automation platform ' + 'running on Python 3.') +PROJECT_LONG_DESCRIPTION = ('Home Assistant is an open-source ' + 'home automation platform running on Python 3. ' + 'Track and control all devices at home and ' + 'automate control. ' + 'Installation in less than a minute.') +PROJECT_CLASSIFIERS = [ + 'Intended Audience :: End Users/Desktop', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3.4', + 'Topic :: Home Automation' +] + +PROJECT_GITHUB_USERNAME = 'home-assistant' +PROJECT_GITHUB_REPOSITORY = 'home-assistant' + +PYPI_URL = 'https://pypi.python.org/pypi/{}'.format(PROJECT_PACKAGE_NAME) +GITHUB_PATH = '{}/{}'.format(PROJECT_GITHUB_USERNAME, + PROJECT_GITHUB_REPOSITORY) +GITHUB_URL = 'https://github.com/{}'.format(GITHUB_PATH) + PLATFORM_FORMAT = '{}.{}' # Can be used to specify a catch all when registering state or event listeners. @@ -27,24 +61,38 @@ CONF_AUTHENTICATION = 'authentication' CONF_BEFORE = 'before' CONF_BELOW = 'below' CONF_BLACKLIST = 'blacklist' +CONF_BRIGHTNESS = 'brightness' CONF_CODE = 'code' +CONF_COMMAND = 'command' +CONF_COMMAND_CLOSE = 'command_close' +CONF_COMMAND_OFF = 'command_off' +CONF_COMMAND_ON = 'command_on' +CONF_COMMAND_OPEN = 'command_open' +CONF_COMMAND_STATE = 'command_state' +CONF_COMMAND_STOP = 'command_stop' CONF_CONDITION = 'condition' +CONF_COVERS = 'covers' CONF_CUSTOMIZE = 'customize' CONF_DEVICE = 'device' +CONF_DEVICES = 'devices' CONF_DISARM_AFTER_TRIGGER = 'disarm_after_trigger' +CONF_DISCOVERY = 'discovery' CONF_DISPLAY_OPTIONS = 'display_options' CONF_ELEVATION = 'elevation' +CONF_EMAIL = 'email' CONF_ENTITY_ID = 'entity_id' CONF_ENTITY_NAMESPACE = 'entity_namespace' CONF_EVENT = 'event' CONF_FILE_PATH = 'file_path' CONF_FILENAME = 'filename' +CONF_FRIENDLY_NAME = 'friendly_name' CONF_HOST = 'host' CONF_HOSTS = 'hosts' CONF_ICON = 'icon' CONF_ID = 'id' CONF_LATITUDE = 'latitude' CONF_LONGITUDE = 'longitude' +CONF_MAC = 'mac' CONF_METHOD = 'method' CONF_MONITORED_CONDITIONS = 'monitored_conditions' CONF_MONITORED_VARIABLES = 'monitored_variables' @@ -53,19 +101,28 @@ CONF_OFFSET = 'offset' CONF_OPTIMISTIC = 'optimistic' CONF_PASSWORD = 'password' CONF_PAYLOAD = 'payload' +CONF_PAYLOAD_OFF = 'payload_off' +CONF_PAYLOAD_ON = 'payload_on' CONF_PENDING_TIME = 'pending_time' +CONF_PIN = 'pin' CONF_PLATFORM = 'platform' CONF_PORT = 'port' CONF_PREFIX = 'prefix' +CONF_RECIPIENT = 'recipient' CONF_RESOURCE = 'resource' CONF_RESOURCES = 'resources' +CONF_RGB = 'rgb' CONF_SCAN_INTERVAL = 'scan_interval' +CONF_SENDER = 'sender' CONF_SENSOR_CLASS = 'sensor_class' +CONF_SENSORS = 'sensors' CONF_SSL = 'ssl' CONF_STATE = 'state' CONF_STRUCTURE = 'structure' +CONF_SWITCHES = 'switches' CONF_TEMPERATURE_UNIT = 'temperature_unit' CONF_TIME_ZONE = 'time_zone' +CONF_TIMEOUT = 'timeout' CONF_TOKEN = 'token' CONF_TRIGGER_TIME = 'trigger_time' CONF_UNIT_OF_MEASUREMENT = 'unit_of_measurement' diff --git a/homeassistant/core.py b/homeassistant/core.py index b77d8356a35..03f9658325f 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -297,13 +297,19 @@ class EventBus(object): else: self._listeners[event_type] = [listener] + def remove_listener(): + """Remove the listener.""" + self._remove_listener(event_type, listener) + + return remove_listener + def listen_once(self, event_type, listener): """Listen once for event of a specific type. To listen to all events specify the constant ``MATCH_ALL`` as event_type. - Returns registered listener that can be used with remove_listener. + Returns function to unsubscribe the listener. """ @ft.wraps(listener) def onetime_listener(event): @@ -317,15 +323,21 @@ class EventBus(object): # This will make sure the second time it does nothing. setattr(onetime_listener, 'run', True) - self.remove_listener(event_type, onetime_listener) + remove_listener() listener(event) - self.listen(event_type, onetime_listener) + remove_listener = self.listen(event_type, onetime_listener) - return onetime_listener + return remove_listener def remove_listener(self, event_type, listener): + """Remove a listener of a specific event_type. (DEPRECATED 0.28).""" + _LOGGER.warning('bus.remove_listener has been deprecated. Please use ' + 'the function returned from calling listen.') + self._remove_listener(event_type, listener) + + def _remove_listener(self, event_type, listener): """Remove a listener of a specific event_type.""" with self._lock: try: @@ -338,7 +350,8 @@ class EventBus(object): except (KeyError, ValueError): # KeyError is key event_type listener did not exist # ValueError if listener did not exist within event_type - pass + _LOGGER.warning('Unable to remove unknown listener %s', + listener) class State(object): @@ -568,6 +581,7 @@ class Service(object): try: if self.schema: call.data = self.schema(call.data) + call.data = MappingProxyType(call.data) self.func(call) except vol.MultipleInvalid as ex: @@ -682,14 +696,13 @@ class ServiceRegistry(object): if call.data[ATTR_SERVICE_CALL_ID] == call_id: executed_event.set() - self._bus.listen(EVENT_SERVICE_EXECUTED, service_executed) + unsub = self._bus.listen(EVENT_SERVICE_EXECUTED, service_executed) self._bus.fire(EVENT_CALL_SERVICE, event_data) if blocking: success = executed_event.wait(SERVICE_CALL_LIMIT) - self._bus.remove_listener( - EVENT_SERVICE_EXECUTED, service_executed) + unsub() return success def _event_to_service_call(self, event): diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index d9c761832dc..1be157c789d 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1,5 +1,6 @@ """Helpers for config validation using voluptuous.""" from datetime import timedelta +import os from urllib.parse import urlparse from typing import Any, Union, TypeVar, Callable, Sequence, List, Dict @@ -65,9 +66,17 @@ def boolean(value: Any) -> bool: return bool(value) -def isfile(value): +def isfile(value: Any) -> str: """Validate that the value is an existing file.""" - return vol.IsFile('not a file')(value) + if value is None: + raise vol.Invalid('None is not file') + file_in = os.path.expanduser(str(value)) + + if not os.path.isfile(file_in): + raise vol.Invalid('not a file') + if not os.access(file_in, os.R_OK): + raise vol.Invalid('file not readable') + return file_in def ensure_list(value: Union[T, Sequence[T]]) -> List[T]: @@ -235,6 +244,20 @@ def template(value): raise vol.Invalid('invalid template ({})'.format(ex)) +def template_complex(value): + """Validate a complex jinja2 template.""" + if isinstance(value, list): + for idx, element in enumerate(value): + value[idx] = template_complex(element) + return value + if isinstance(value, dict): + for key, element in value.items(): + value[key] = template_complex(element) + return value + + return template(value) + + def time(value): """Validate time.""" time_val = dt_util.parse_time(value) @@ -301,7 +324,7 @@ SERVICE_SCHEMA = vol.All(vol.Schema({ vol.Exclusive('service', 'service name'): service, vol.Exclusive('service_template', 'service name'): template, vol.Optional('data'): dict, - vol.Optional('data_template'): {match_all: template}, + vol.Optional('data_template'): {match_all: template_complex}, vol.Optional(CONF_ENTITY_ID): entity_ids, }), has_at_least_one_key('service', 'service_template')) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 61cda43d431..0b4768b809d 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -195,6 +195,10 @@ class Entity(object): return self.hass.states.set( self.entity_id, state, attr, self.force_update) + def remove(self) -> None: + """Remove entitiy from HASS.""" + self.hass.states.remove(self.entity_id) + def _attr_setter(self, name, typ, attr, attrs): """Helper method to populate attributes based on properties.""" if attr in attrs: diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 898a445c788..3146d703d19 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -1,11 +1,14 @@ """Helpers for components that manage entities.""" from threading import Lock -from homeassistant.bootstrap import prepare_setup_platform -from homeassistant.components import group +from homeassistant import config as conf_util +from homeassistant.bootstrap import (prepare_setup_platform, + prepare_setup_component) from homeassistant.const import ( ATTR_ENTITY_ID, CONF_SCAN_INTERVAL, CONF_ENTITY_NAMESPACE, DEVICE_DEFAULT_NAME) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import get_component from homeassistant.helpers import config_per_platform, discovery from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.event import track_utc_time_change @@ -32,13 +35,14 @@ class EntityComponent(object): self.entities = {} self.group = None - self.is_polling = False self.config = None self.lock = Lock() - self.add_entities = EntityPlatform(self, self.scan_interval, - None).add_entities + self._platforms = { + 'core': EntityPlatform(self, self.scan_interval, None), + } + self.add_entities = self._platforms['core'].add_entities def setup(self, config): """Set up a full entity component. @@ -85,17 +89,22 @@ class EntityComponent(object): return # Config > Platform > Component - scan_interval = platform_config.get( - CONF_SCAN_INTERVAL, - getattr(platform, 'SCAN_INTERVAL', self.scan_interval)) + scan_interval = (platform_config.get(CONF_SCAN_INTERVAL) or + getattr(platform, 'SCAN_INTERVAL', None) or + self.scan_interval) entity_namespace = platform_config.get(CONF_ENTITY_NAMESPACE) + key = (platform_type, scan_interval, entity_namespace) + + if key not in self._platforms: + self._platforms[key] = EntityPlatform(self, scan_interval, + entity_namespace) + entity_platform = self._platforms[key] + try: - platform.setup_platform( - self.hass, platform_config, - EntityPlatform(self, scan_interval, - entity_namespace).add_entities, - discovery_info) + platform.setup_platform(self.hass, platform_config, + entity_platform.add_entities, + discovery_info) self.hass.config.components.append( '{}.{}'.format(self.domain, platform_type)) @@ -129,12 +138,46 @@ class EntityComponent(object): def update_group(self): """Set up and/or update component group.""" if self.group is None and self.group_name is not None: + group = get_component('group') self.group = group.Group(self.hass, self.group_name, user_defined=False) if self.group is not None: self.group.update_tracked_entity_ids(self.entities.keys()) + def reset(self): + """Remove entities and reset the entity component to initial values.""" + with self.lock: + for platform in self._platforms.values(): + platform.reset() + + self._platforms = { + 'core': self._platforms['core'] + } + self.entities = {} + self.config = None + + if self.group is not None: + self.group.stop() + self.group = None + + def prepare_reload(self): + """Prepare reloading this entity component.""" + try: + path = conf_util.find_config_file(self.hass.config.config_dir) + conf = conf_util.load_yaml_config_file(path) + except HomeAssistantError as err: + self.logger.error(err) + return None + + conf = prepare_setup_component(self.hass, conf, self.domain) + + if conf is None: + return None + + self.reset() + return conf + class EntityPlatform(object): """Keep track of entities for a single platform.""" @@ -146,7 +189,7 @@ class EntityPlatform(object): self.scan_interval = scan_interval self.entity_namespace = entity_namespace self.platform_entities = [] - self.is_polling = False + self._unsub_polling = None def add_entities(self, new_entities): """Add entities for a single platform.""" @@ -157,17 +200,23 @@ class EntityPlatform(object): self.component.update_group() - if self.is_polling or \ + if self._unsub_polling is not None or \ not any(entity.should_poll for entity in self.platform_entities): return - self.is_polling = True - - track_utc_time_change( + self._unsub_polling = track_utc_time_change( self.component.hass, self._update_entity_states, second=range(0, 60, self.scan_interval)) + def reset(self): + """Remove all entities and reset data.""" + for entity in self.platform_entities: + entity.remove() + if self._unsub_polling is not None: + self._unsub_polling() + self._unsub_polling = None + def _update_entity_states(self, now): """Update the states of all the polling entities.""" with self.component.lock: diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 9bc6910c685..512b173a249 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -14,8 +14,7 @@ def track_state_change(hass, entity_ids, action, from_state=None, entity_ids, from_state and to_state can be string or list. Use list to match multiple. - Returns the listener that listens on the bus for EVENT_STATE_CHANGED. - Pass the return value into hass.bus.remove_listener to remove it. + Returns a function that can be called to remove the listener. """ from_state = _process_state_match(from_state) to_state = _process_state_match(to_state) @@ -50,9 +49,7 @@ def track_state_change(hass, entity_ids, action, from_state=None, event.data.get('old_state'), event.data.get('new_state')) - hass.bus.listen(EVENT_STATE_CHANGED, state_change_listener) - - return state_change_listener + return hass.bus.listen(EVENT_STATE_CHANGED, state_change_listener) def track_point_in_time(hass, action, point_in_time): @@ -77,23 +74,20 @@ def track_point_in_utc_time(hass, action, point_in_time): """Listen for matching time_changed events.""" now = event.data[ATTR_NOW] - if now >= point_in_time and \ - not hasattr(point_in_time_listener, 'run'): + if now < point_in_time or hasattr(point_in_time_listener, 'run'): + return - # Set variable so that we will never run twice. - # Because the event bus might have to wait till a thread comes - # available to execute this listener it might occur that the - # listener gets lined up twice to be executed. This will make - # sure the second time it does nothing. - point_in_time_listener.run = True + # Set variable so that we will never run twice. + # Because the event bus might have to wait till a thread comes + # available to execute this listener it might occur that the + # listener gets lined up twice to be executed. This will make + # sure the second time it does nothing. + point_in_time_listener.run = True + remove() + action(now) - hass.bus.remove_listener(EVENT_TIME_CHANGED, - point_in_time_listener) - - action(now) - - hass.bus.listen(EVENT_TIME_CHANGED, point_in_time_listener) - return point_in_time_listener + remove = hass.bus.listen(EVENT_TIME_CHANGED, point_in_time_listener) + return remove def track_sunrise(hass, action, offset=None): @@ -112,10 +106,19 @@ def track_sunrise(hass, action, offset=None): def sunrise_automation_listener(now): """Called when it's time for action.""" - track_point_in_utc_time(hass, sunrise_automation_listener, next_rise()) + nonlocal remove + remove = track_point_in_utc_time(hass, sunrise_automation_listener, + next_rise()) action() - track_point_in_utc_time(hass, sunrise_automation_listener, next_rise()) + remove = track_point_in_utc_time(hass, sunrise_automation_listener, + next_rise()) + + def remove_listener(): + """Remove sunrise listener.""" + remove() + + return remove_listener def track_sunset(hass, action, offset=None): @@ -134,10 +137,19 @@ def track_sunset(hass, action, offset=None): def sunset_automation_listener(now): """Called when it's time for action.""" - track_point_in_utc_time(hass, sunset_automation_listener, next_set()) + nonlocal remove + remove = track_point_in_utc_time(hass, sunset_automation_listener, + next_set()) action() - track_point_in_utc_time(hass, sunset_automation_listener, next_set()) + remove = track_point_in_utc_time(hass, sunset_automation_listener, + next_set()) + + def remove_listener(): + """Remove sunset listener.""" + remove() + + return remove_listener # pylint: disable=too-many-arguments @@ -152,8 +164,7 @@ def track_utc_time_change(hass, action, year=None, month=None, day=None, """Fire every time event that comes in.""" action(event.data[ATTR_NOW]) - hass.bus.listen(EVENT_TIME_CHANGED, time_change_listener) - return time_change_listener + return hass.bus.listen(EVENT_TIME_CHANGED, time_change_listener) pmp = _process_time_match year, month, day = pmp(year), pmp(month), pmp(day) @@ -178,8 +189,7 @@ def track_utc_time_change(hass, action, year=None, month=None, day=None, action(now) - hass.bus.listen(EVENT_TIME_CHANGED, pattern_time_change_listener) - return pattern_time_change_listener + return hass.bus.listen(EVENT_TIME_CHANGED, pattern_time_change_listener) # pylint: disable=too-many-arguments diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 008fdb9374d..73ef08ce1ff 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -7,7 +7,7 @@ from typing import Optional, Sequence import voluptuous as vol from homeassistant.core import HomeAssistant -from homeassistant.const import EVENT_TIME_CHANGED, CONF_CONDITION +from homeassistant.const import CONF_CONDITION from homeassistant.helpers import ( service, condition, template, config_validation as cv) from homeassistant.helpers.event import track_point_in_utc_time @@ -47,7 +47,7 @@ class Script(): self.can_cancel = any(CONF_DELAY in action for action in self.sequence) self._lock = threading.Lock() - self._delay_listener = None + self._unsub_delay_listener = None @property def is_running(self) -> bool: @@ -72,7 +72,7 @@ class Script(): # Call ourselves in the future to continue work def script_delay(now): """Called after delay is done.""" - self._delay_listener = None + self._unsub_delay_listener = None self.run(variables) delay = action[CONF_DELAY] @@ -83,7 +83,7 @@ class Script(): cv.positive_timedelta)( template.render(self.hass, delay)) - self._delay_listener = track_point_in_utc_time( + self._unsub_delay_listener = track_point_in_utc_time( self.hass, script_delay, date_util.utcnow() + delay) self._cur = cur + 1 @@ -139,10 +139,9 @@ class Script(): def _remove_listener(self): """Remove point in time listener, if any.""" - if self._delay_listener: - self.hass.bus.remove_listener(EVENT_TIME_CHANGED, - self._delay_listener) - self._delay_listener = None + if self._unsub_delay_listener: + self._unsub_delay_listener() + self._unsub_delay_listener = None def _log(self, msg): """Logger helper.""" diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index b594889fd77..21cfb0aab54 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -63,9 +63,21 @@ def call_from_config(hass, config, blocking=False, variables=None, domain, service_name = domain_service.split('.', 1) service_data = dict(config.get(CONF_SERVICE_DATA, {})) + def _data_template_creator(value): + """Recursive template creator helper function.""" + if isinstance(value, list): + for idx, element in enumerate(value): + value[idx] = _data_template_creator(element) + return value + if isinstance(value, dict): + for key, element in value.items(): + value[key] = _data_template_creator(element) + return value + return template.render(hass, value, variables) + if CONF_SERVICE_DATA_TEMPLATE in config: for key, value in config[CONF_SERVICE_DATA_TEMPLATE].items(): - service_data[key] = template.render(hass, value, variables) + service_data[key] = _data_template_creator(value) if CONF_SERVICE_ENTITY_ID in config: service_data[ATTR_ENTITY_ID] = config[CONF_SERVICE_ENTITY_ID] diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index fab081cc5c5..e083534f828 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -6,11 +6,11 @@ import logging import jinja2 from jinja2.sandbox import ImmutableSandboxedEnvironment -from homeassistant.components import group from homeassistant.const import STATE_UNKNOWN, ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import State from homeassistant.exceptions import TemplateError from homeassistant.helpers import location as loc_helper +from homeassistant.loader import get_component from homeassistant.util import convert, dt as dt_util, location as loc_util _LOGGER = logging.getLogger(__name__) @@ -169,6 +169,8 @@ class LocationMethods(object): else: gr_entity_id = str(entities) + group = get_component('group') + states = [self._hass.states.get(entity_id) for entity_id in group.expand_entity_ids(self._hass, [gr_entity_id])] @@ -250,6 +252,20 @@ def multiply(value, amount): return value +def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True): + """Filter to convert given timestamp to format.""" + try: + date = dt_util.utc_from_timestamp(value) + + if local: + date = dt_util.as_local(date) + + return date.strftime(date_format) + except (ValueError, TypeError): + # If timestamp can't be converted + return value + + def timestamp_local(value): """Filter to convert given timestamp to local date/time.""" try: @@ -261,7 +277,7 @@ def timestamp_local(value): def timestamp_utc(value): - """Filter to convert gibrn timestamp to UTC date/time.""" + """Filter to convert given timestamp to UTC date/time.""" try: return dt_util.utc_from_timestamp(value).strftime(DATE_STR_FORMAT) except (ValueError, TypeError): @@ -287,5 +303,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): ENV = TemplateEnvironment() ENV.filters['round'] = forgiving_round ENV.filters['multiply'] = multiply +ENV.filters['timestamp_custom'] = timestamp_custom ENV.filters['timestamp_local'] = timestamp_local ENV.filters['timestamp_utc'] = timestamp_utc diff --git a/homeassistant/remote.py b/homeassistant/remote.py index 8e62cdd044a..4564878a5ad 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -211,6 +211,7 @@ class EventForwarder(object): self._targets = {} self._lock = threading.Lock() + self._unsub_listener = None def connect(self, api): """Attach to a Home Assistant instance and forward events. @@ -218,9 +219,9 @@ class EventForwarder(object): Will overwrite old target if one exists with same host/port. """ with self._lock: - if len(self._targets) == 0: - # First target we get, setup listener for events - self.hass.bus.listen(ha.MATCH_ALL, self._event_listener) + if self._unsub_listener is None: + self._unsub_listener = self.hass.bus.listen( + ha.MATCH_ALL, self._event_listener) key = (api.host, api.port) @@ -235,8 +236,8 @@ class EventForwarder(object): if len(self._targets) == 0: # Remove event listener if no forwarding targets present - self.hass.bus.remove_listener(ha.MATCH_ALL, - self._event_listener) + self._unsub_listener() + self._unsub_listener = None return did_remove diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 624452b0592..d1bf12187e8 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -221,14 +221,18 @@ def check(config_path): try: bootstrap.from_config_file(config_path, skip_pip=True) - res['secret_cache'] = yaml.__SECRET_CACHE - return res + res['secret_cache'] = dict(yaml.__SECRET_CACHE) + except Exception as err: # pylint: disable=broad-except + print(color('red', 'Fatal error while loading config:'), str(err)) finally: # Stop all patches for pat in PATCHES.values(): pat.stop() # Ensure !secrets point to the original function yaml.yaml.SafeLoader.add_constructor('!secret', yaml._secret_yaml) + bootstrap.clear_secret_cache() + + return res def dump_dict(layer, indent_count=1, listi=False, **kwargs): diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index b834ac8048c..035a96b657e 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -121,6 +121,16 @@ def _ordered_dict(loader: SafeLineLoader, line = getattr(node, '__line__', 'unknown') if line != 'unknown' and (min_line is None or line < min_line): min_line = line + + try: + hash(key) + except TypeError: + fname = getattr(loader.stream, 'name', '') + raise yaml.MarkedYAMLError( + context="invalid key: \"{}\"".format(key), + context_mark=yaml.Mark(fname, 0, min_line, -1, None, None) + ) + if key in seen: fname = getattr(loader.stream, 'name', '') first_mark = yaml.Mark(fname, 0, seen[key], -1, None, None) diff --git a/requirements_all.txt b/requirements_all.txt index 56d445daf54..c0ef98ffdc1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -26,7 +26,7 @@ SoCo==0.11.1 TwitterAPI==2.4.2 # homeassistant.components.http -Werkzeug==0.11.10 +Werkzeug==0.11.11 # homeassistant.components.apcupsd apcaccess==0.0.4 @@ -34,6 +34,9 @@ apcaccess==0.0.4 # homeassistant.components.sun astral==1.2 +# homeassistant.components.sensor.linux_battery +batinfo==0.3 + # homeassistant.components.light.blinksticklight blinkstick==1.1.8 @@ -52,6 +55,9 @@ boto3==1.3.1 # homeassistant.components.http cherrypy==7.1.0 +# homeassistant.components.sensor.coinmarketcap +coinmarketcap==2.0.1 + # homeassistant.scripts.check_config colorlog>2.1,<3 @@ -75,11 +81,14 @@ enocean==0.31 # homeassistant.components.thermostat.honeywell evohomeclient==0.2.5 +# homeassistant.components.sensor.fastdotcom +fastdotcom==0.0.1 + # homeassistant.components.feedreader feedparser==5.2.1 # homeassistant.components.sensor.fitbit -fitbit==0.2.2 +fitbit==0.2.3 # homeassistant.components.sensor.fixer fixerio==0.1.1 @@ -100,11 +109,11 @@ gntp==1.0.3 googlemaps==2.4.4 # homeassistant.components.sensor.gpsd -gps3==0.33.2 +gps3==0.33.3 # homeassistant.components.binary_sensor.ffmpeg # homeassistant.components.camera.ffmpeg -ha-ffmpeg==0.9 +ha-ffmpeg==0.10 # homeassistant.components.mqtt.server hbmqtt==0.7.1 @@ -123,7 +132,7 @@ hikvision==0.4 https://github.com/Danielhiversen/flux_led/archive/0.6.zip#flux_led==0.6 # homeassistant.components.switch.dlink -https://github.com/LinuxChristian/pyW215/archive/v0.1.1.zip#pyW215==0.1.1 +https://github.com/LinuxChristian/pyW215/archive/v0.3.4.zip#pyW215==0.3.4 # homeassistant.components.media_player.webostv # homeassistant.components.notify.webostv @@ -137,7 +146,7 @@ https://github.com/TheRealLink/pythinkingcleaner/archive/v0.0.2.zip#pythinkingcl https://github.com/Xorso/pyalarmdotcom/archive/0.1.1.zip#pyalarmdotcom==0.1.1 # homeassistant.components.media_player.braviatv -https://github.com/aparraga/braviarc/archive/0.3.3.zip#braviarc==0.3.3 +https://github.com/aparraga/braviarc/archive/0.3.5.zip#braviarc==0.3.5 # homeassistant.components.media_player.roku https://github.com/bah2830/python-roku/archive/3.1.2.zip#roku==3.1.2 @@ -155,7 +164,7 @@ https://github.com/danieljkemp/onkyo-eiscp/archive/python3.zip#onkyo-eiscp==0.9. https://github.com/gadgetreactor/pyHS100/archive/master.zip#pyHS100==0.1.2 # homeassistant.components.netatmo -https://github.com/jabesq/netatmo-api-python/archive/v0.5.0.zip#lnetatmo==0.5.0 +https://github.com/jabesq/netatmo-api-python/archive/master.zip#lnetatmo==0.5.0 # homeassistant.components.sensor.sabnzbd https://github.com/jamespcole/home-assistant-nzb-clients/archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip#python-sabnzbd==0.1 @@ -166,9 +175,6 @@ https://github.com/kellerza/pyqwikswitch/archive/v0.4.zip#pyqwikswitch==0.4 # homeassistant.components.media_player.russound_rnet https://github.com/laf/russound/archive/0.1.6.zip#russound==0.1.6 -# homeassistant.components.sensor.fastdotcom -https://github.com/nkgilley/fast.com/archive/master.zip#fastdotcom==0.0.1 - # homeassistant.components.ecobee https://github.com/nkgilley/python-ecobee-api/archive/4856a704670c53afe1882178a89c209b5f98533d.zip#python-ecobee==0.0.6 @@ -179,9 +185,6 @@ https://github.com/nkgilley/python-join-api/archive/3e1e849f1af0b4080f551b62270c # homeassistant.components.switch.edimax https://github.com/rkabadi/pyedimax/archive/365301ce3ff26129a7910c501ead09ea625f3700.zip#pyedimax==0.1 -# homeassistant.components.sensor.temper -https://github.com/rkabadi/temper-python/archive/3dbdaf2d87b8db9a3cd6e5585fc704537dd2d09b.zip#temperusb==1.2.3 - # homeassistant.components.sensor.gtfs https://github.com/robbiet480/pygtfs/archive/00546724e4bbcb3053110d844ca44e2246267dd8.zip#pygtfs==0.1.3 @@ -270,7 +273,7 @@ pmsensor==0.3 proliphix==0.3.1 # homeassistant.components.sensor.systemmonitor -psutil==4.3.0 +psutil==4.3.1 # homeassistant.components.wink # homeassistant.components.binary_sensor.wink @@ -343,10 +346,10 @@ pynetio==0.1.6 pynx584==0.2 # homeassistant.components.sensor.openweathermap -pyowm==2.3.2 +pyowm==2.4.0 # homeassistant.components.switch.acer_projector -pyserial<=3.1 +pyserial==3.1.1 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp @@ -394,7 +397,7 @@ python-twitch==1.3.0 python-wink==0.7.13 # homeassistant.components.keyboard -pyuserinput==0.1.9 +pyuserinput==0.1.11 # homeassistant.components.vera pyvera==0.2.15 @@ -422,16 +425,16 @@ schiene==0.17 scsgate==0.1.0 # homeassistant.components.notify.sendgrid -sendgrid==3.2.10 +sendgrid==3.4.0 # homeassistant.components.notify.slack -slacker==0.9.24 +slacker==0.9.25 # homeassistant.components.notify.xmpp sleekxmpp==1.3.1 # homeassistant.components.media_player.snapcast -snapcast==1.2.1 +snapcast==1.2.2 # homeassistant.components.climate.honeywell # homeassistant.components.thermostat.honeywell @@ -460,6 +463,9 @@ tellcore-py==1.1.2 # homeassistant.components.tellduslive tellive-py==0.5.2 +# homeassistant.components.sensor.temper +temperusb==1.5.1 + # homeassistant.components.sensor.transmission # homeassistant.components.switch.transmission transmissionrpc==0.11 @@ -468,7 +474,7 @@ transmissionrpc==0.11 twilio==5.4.0 # homeassistant.components.sensor.uber -uber_rides==0.2.4 +uber_rides==0.2.5 # homeassistant.components.device_tracker.unifi unifi==1.2.5 @@ -491,6 +497,9 @@ websocket-client==0.37.0 # homeassistant.components.zigbee xbee-helper==0.0.7 +# homeassistant.components.sensor.xbox_live +xboxapi==0.1.1 + # homeassistant.components.sensor.swiss_hydrological_data # homeassistant.components.sensor.yr xmltodict==0.10.2 diff --git a/requirements_docs.txt b/requirements_docs.txt new file mode 100644 index 00000000000..df88ba8fb58 --- /dev/null +++ b/requirements_docs.txt @@ -0,0 +1,3 @@ +Sphinx==1.4.6 +sphinx-autodoc-typehints==1.1.0 +sphinx-autodoc-annotation==1.0.post1 diff --git a/setup.cfg b/setup.cfg index 98a4f54d55d..6d952083a31 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,3 +7,6 @@ norecursedirs = .git testing_config [flake8] exclude = .venv,.git,.tox,docs,www_static,venv,bin,lib,deps,build + +[pydocstyle] +match_dir = ^((?!\.|www_static).)*$ diff --git a/setup.py b/setup.py index caa5b177b5c..67366bd7d83 100755 --- a/setup.py +++ b/setup.py @@ -1,12 +1,15 @@ #!/usr/bin/env python3 import os from setuptools import setup, find_packages -from homeassistant.const import __version__ +from homeassistant.const import (__version__, PROJECT_PACKAGE_NAME, + PROJECT_LICENSE, PROJECT_URL, + PROJECT_EMAIL, PROJECT_DESCRIPTION, + PROJECT_CLASSIFIERS, GITHUB_URL, + PROJECT_AUTHOR) -PACKAGE_NAME = 'homeassistant' HERE = os.path.abspath(os.path.dirname(__file__)) -DOWNLOAD_URL = ('https://github.com/home-assistant/home-assistant/archive/' - '{}.zip'.format(__version__)) +DOWNLOAD_URL = ('{}/archive/' + '{}.zip'.format(GITHUB_URL, __version__)) PACKAGES = find_packages(exclude=['tests', 'tests.*']) @@ -21,14 +24,14 @@ REQUIRES = [ ] setup( - name=PACKAGE_NAME, + name=PROJECT_PACKAGE_NAME, version=__version__, - license='MIT License', - url='https://home-assistant.io/', + license=PROJECT_LICENSE, + url=PROJECT_URL, download_url=DOWNLOAD_URL, - author='Home Assistant', - author_email='hello@home-assistant.io', - description='Open-source home automation platform running on Python 3.', + author=PROJECT_AUTHOR, + author_email=PROJECT_EMAIL, + description=PROJECT_DESCRIPTION, packages=PACKAGES, include_package_data=True, zip_safe=False, @@ -41,12 +44,5 @@ setup( 'hass = homeassistant.__main__:main' ] }, - classifiers=[ - 'Intended Audience :: End Users/Desktop', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3.4', - 'Topic :: Home Automation' - ], + classifiers=PROJECT_CLASSIFIERS, ) diff --git a/tests/common.py b/tests/common.py index e51e4ba048a..3c6815ece02 100644 --- a/tests/common.py +++ b/tests/common.py @@ -44,6 +44,7 @@ def get_test_home_assistant(num_threads=None): hass.config.elevation = 0 hass.config.time_zone = date_util.get_time_zone('US/Pacific') hass.config.units = METRIC_SYSTEM + hass.config.skip_pip = True if 'custom_components.test' not in loader.AVAILABLE_COMPONENTS: loader.prepare(hass) @@ -246,20 +247,23 @@ def patch_yaml_files(files_dict, endswith=True): """Patch load_yaml with a dictionary of yaml files.""" # match using endswith, start search with longest string matchlist = sorted(list(files_dict.keys()), key=len) if endswith else [] - # matchlist.sort(key=len) def mock_open_f(fname, **_): """Mock open() in the yaml module, used by load_yaml.""" # Return the mocked file on full match if fname in files_dict: _LOGGER.debug('patch_yaml_files match %s', fname) - return StringIO(files_dict[fname]) + res = StringIO(files_dict[fname]) + setattr(res, 'name', fname) + return res # Match using endswith for ends in matchlist: if fname.endswith(ends): _LOGGER.debug('patch_yaml_files end match %s: %s', ends, fname) - return StringIO(files_dict[ends]) + res = StringIO(files_dict[ends]) + setattr(res, 'name', fname) + return res # Fallback for hass.components (i.e. services.yaml) if 'homeassistant/components' in fname: @@ -267,6 +271,6 @@ def patch_yaml_files(files_dict, endswith=True): return open(fname, encoding='utf-8') # Not found - raise IOError('File not found: {}'.format(fname)) + raise FileNotFoundError('File not found: {}'.format(fname)) return patch.object(yaml, 'open', mock_open_f, create=True) diff --git a/tests/components/automation/test_event.py b/tests/components/automation/test_event.py index ef5d380075b..80b1f507651 100644 --- a/tests/components/automation/test_event.py +++ b/tests/components/automation/test_event.py @@ -44,6 +44,13 @@ class TestAutomationEvent(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) + automation.turn_off(self.hass) + self.hass.pool.block_till_done() + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_fires_on_event_with_data(self): """Test the firing of events with data.""" assert _setup_component(self.hass, automation.DOMAIN, { diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index e90ffe8d765..3d69cca2d32 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1,9 +1,12 @@ """The tests for the automation component.""" import unittest +from unittest.mock import patch from homeassistant.bootstrap import _setup_component import homeassistant.components.automation as automation from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.exceptions import HomeAssistantError +import homeassistant.util.dt as dt_util from tests.common import get_test_home_assistant @@ -45,6 +48,7 @@ class TestAutomation(unittest.TestCase): """Test service data.""" assert _setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { + 'alias': 'hello', 'trigger': { 'platform': 'event', 'event_type': 'test_event', @@ -59,10 +63,17 @@ class TestAutomation(unittest.TestCase): } }) - self.hass.bus.fire('test_event') - self.hass.pool.block_till_done() - self.assertEqual(1, len(self.calls)) - self.assertEqual('event - test_event', self.calls[0].data['some']) + time = dt_util.utcnow() + + with patch('homeassistant.components.automation.utcnow', + return_value=time): + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + assert len(self.calls) == 1 + assert 'event - test_event' == self.calls[0].data['some'] + state = self.hass.states.get('automation.hello') + assert state is not None + assert state.attributes.get('last_triggered') == time def test_service_specify_entity_id(self): """Test service data.""" @@ -347,3 +358,195 @@ class TestAutomation(unittest.TestCase): assert len(self.calls) == 2 assert self.calls[0].data['position'] == 0 assert self.calls[1].data['position'] == 1 + + def test_services(self): + """Test the automation services for turning entities on/off.""" + entity_id = 'automation.hello' + + assert self.hass.states.get(entity_id) is None + assert not automation.is_on(self.hass, entity_id) + + assert _setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'alias': 'hello', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': { + 'service': 'test.automation', + } + } + }) + + assert self.hass.states.get(entity_id) is not None + assert automation.is_on(self.hass, entity_id) + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + assert len(self.calls) == 1 + + automation.turn_off(self.hass, entity_id) + self.hass.pool.block_till_done() + + assert not automation.is_on(self.hass, entity_id) + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + assert len(self.calls) == 1 + + automation.toggle(self.hass, entity_id) + self.hass.pool.block_till_done() + + assert automation.is_on(self.hass, entity_id) + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + assert len(self.calls) == 2 + + automation.trigger(self.hass, entity_id) + self.hass.pool.block_till_done() + assert len(self.calls) == 3 + + automation.turn_off(self.hass, entity_id) + self.hass.pool.block_till_done() + automation.trigger(self.hass, entity_id) + self.hass.pool.block_till_done() + assert len(self.calls) == 4 + + automation.turn_on(self.hass, entity_id) + self.hass.pool.block_till_done() + assert automation.is_on(self.hass, entity_id) + + @patch('homeassistant.config.load_yaml_config_file', return_value={ + automation.DOMAIN: { + 'alias': 'bye', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event2', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'event': '{{ trigger.event.event_type }}' + } + } + } + }) + def test_reload_config_service(self, mock_load_yaml): + """Test the reload config service.""" + assert _setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'alias': 'hello', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'event': '{{ trigger.event.event_type }}' + } + } + } + }) + assert self.hass.states.get('automation.hello') is not None + assert self.hass.states.get('automation.bye') is None + listeners = self.hass.bus.listeners + assert listeners.get('test_event') == 1 + assert listeners.get('test_event2') is None + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + assert len(self.calls) == 1 + assert self.calls[0].data.get('event') == 'test_event' + + automation.reload(self.hass) + self.hass.pool.block_till_done() + + assert self.hass.states.get('automation.hello') is None + assert self.hass.states.get('automation.bye') is not None + listeners = self.hass.bus.listeners + assert listeners.get('test_event') is None + assert listeners.get('test_event2') == 1 + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + assert len(self.calls) == 1 + + self.hass.bus.fire('test_event2') + self.hass.pool.block_till_done() + assert len(self.calls) == 2 + assert self.calls[1].data.get('event') == 'test_event2' + + @patch('homeassistant.config.load_yaml_config_file', return_value={ + automation.DOMAIN: 'not valid', + }) + def test_reload_config_when_invalid_config(self, mock_load_yaml): + """Test the reload config service handling invalid config.""" + assert _setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'alias': 'hello', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'event': '{{ trigger.event.event_type }}' + } + } + } + }) + assert self.hass.states.get('automation.hello') is not None + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + assert len(self.calls) == 1 + assert self.calls[0].data.get('event') == 'test_event' + + automation.reload(self.hass) + self.hass.pool.block_till_done() + + assert self.hass.states.get('automation.hello') is not None + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + assert len(self.calls) == 2 + + def test_reload_config_handles_load_fails(self): + """Test the reload config service.""" + assert _setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'alias': 'hello', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'event': '{{ trigger.event.event_type }}' + } + } + } + }) + assert self.hass.states.get('automation.hello') is not None + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + + assert len(self.calls) == 1 + assert self.calls[0].data.get('event') == 'test_event' + + with patch('homeassistant.config.load_yaml_config_file', + side_effect=HomeAssistantError('bla')): + automation.reload(self.hass) + self.hass.pool.block_till_done() + + assert self.hass.states.get('automation.hello') is not None + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + assert len(self.calls) == 2 diff --git a/tests/components/automation/test_mqtt.py b/tests/components/automation/test_mqtt.py index 29d55b424f2..9bd22d0675c 100644 --- a/tests/components/automation/test_mqtt.py +++ b/tests/components/automation/test_mqtt.py @@ -50,6 +50,12 @@ class TestAutomationMQTT(unittest.TestCase): self.assertEqual('mqtt - test-topic - test_payload', self.calls[0].data['some']) + automation.turn_off(self.hass) + self.hass.pool.block_till_done() + fire_mqtt_message(self.hass, 'test-topic', 'test_payload') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_fires_on_topic_and_payload_match(self): """Test if message is fired on topic and payload match.""" assert _setup_component(self.hass, automation.DOMAIN, { diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index f7d1447632f..9ee8514052c 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -45,6 +45,14 @@ class TestAutomationNumericState(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) + # Set above 12 so the automation will fire again + self.hass.states.set('test.entity', 12) + automation.turn_off(self.hass) + self.hass.pool.block_till_done() + self.hass.states.set('test.entity', 9) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_fires_on_entity_change_over_to_below(self): """"Test the firing with changed entity.""" self.hass.states.set('test.entity', 11) diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py index 4a6971124b6..0b715cb365c 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -59,6 +59,12 @@ class TestAutomationState(unittest.TestCase): 'state - test.entity - hello - world - None', self.calls[0].data['some']) + automation.turn_off(self.hass) + self.hass.pool.block_till_done() + self.hass.states.set('test.entity', 'planet') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_fires_on_entity_change_with_from_filter(self): """Test for firing on entity change with filter.""" assert _setup_component(self.hass, automation.DOMAIN, { diff --git a/tests/components/automation/test_sun.py b/tests/components/automation/test_sun.py index 745e7c060ca..d3bbd254e1b 100644 --- a/tests/components/automation/test_sun.py +++ b/tests/components/automation/test_sun.py @@ -54,6 +54,18 @@ class TestAutomationSun(unittest.TestCase): } }) + automation.turn_off(self.hass) + self.hass.pool.block_till_done() + + fire_time_changed(self.hass, trigger_time) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + with patch('homeassistant.util.dt.utcnow', + return_value=now): + automation.turn_on(self.hass) + self.hass.pool.block_till_done() + fire_time_changed(self.hass, trigger_time) self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) diff --git a/tests/components/automation/test_template.py b/tests/components/automation/test_template.py index a643b731492..a33da951cc8 100644 --- a/tests/components/automation/test_template.py +++ b/tests/components/automation/test_template.py @@ -45,6 +45,13 @@ class TestAutomationTemplate(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) + automation.turn_off(self.hass) + self.hass.pool.block_till_done() + + self.hass.states.set('test.entity', 'planet') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_fires_on_change_str(self): """Test for firing on change.""" assert _setup_component(self.hass, automation.DOMAIN, { @@ -149,6 +156,9 @@ class TestAutomationTemplate(unittest.TestCase): } }) + self.hass.pool.block_till_done() + self.calls = [] + self.hass.states.set('test.entity', 'hello') self.hass.pool.block_till_done() self.assertEqual(0, len(self.calls)) @@ -209,9 +219,12 @@ class TestAutomationTemplate(unittest.TestCase): } }) + self.hass.pool.block_till_done() + self.calls = [] + self.hass.states.set('test.entity', 'world') self.hass.pool.block_till_done() - self.assertEqual(0, len(self.calls)) + assert len(self.calls) == 0 def test_if_fires_on_change_with_template_advanced(self): """Test for firing on change with template advanced.""" @@ -237,6 +250,9 @@ class TestAutomationTemplate(unittest.TestCase): } }) + self.hass.pool.block_till_done() + self.calls = [] + self.hass.states.set('test.entity', 'world') self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) @@ -287,29 +303,32 @@ class TestAutomationTemplate(unittest.TestCase): } }) + self.hass.pool.block_till_done() + self.calls = [] + self.hass.states.set('test.entity', 'world') self.hass.pool.block_till_done() - self.assertEqual(0, len(self.calls)) + assert len(self.calls) == 0 self.hass.states.set('test.entity', 'home') self.hass.pool.block_till_done() - self.assertEqual(1, len(self.calls)) + assert len(self.calls) == 1 self.hass.states.set('test.entity', 'work') self.hass.pool.block_till_done() - self.assertEqual(1, len(self.calls)) + assert len(self.calls) == 1 self.hass.states.set('test.entity', 'not_home') self.hass.pool.block_till_done() - self.assertEqual(1, len(self.calls)) + assert len(self.calls) == 1 self.hass.states.set('test.entity', 'world') self.hass.pool.block_till_done() - self.assertEqual(1, len(self.calls)) + assert len(self.calls) == 1 self.hass.states.set('test.entity', 'home') self.hass.pool.block_till_done() - self.assertEqual(2, len(self.calls)) + assert len(self.calls) == 2 def test_if_action(self): """Test for firing if action.""" diff --git a/tests/components/automation/test_time.py b/tests/components/automation/test_time.py index b36ce8c92b5..3c195f2eb38 100644 --- a/tests/components/automation/test_time.py +++ b/tests/components/automation/test_time.py @@ -43,7 +43,13 @@ class TestAutomationTime(unittest.TestCase): }) fire_time_changed(self.hass, dt_util.utcnow().replace(hour=0)) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + automation.turn_off(self.hass) + self.hass.pool.block_till_done() + + fire_time_changed(self.hass, dt_util.utcnow().replace(hour=0)) self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) diff --git a/tests/components/automation/test_zone.py b/tests/components/automation/test_zone.py index 24980b466bf..9d4161547ef 100644 --- a/tests/components/automation/test_zone.py +++ b/tests/components/automation/test_zone.py @@ -74,6 +74,24 @@ class TestAutomationZone(unittest.TestCase): 'zone - test.entity - hello - hello - test', self.calls[0].data['some']) + # Set out of zone again so we can trigger call + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758 + }) + self.hass.pool.block_till_done() + + automation.turn_off(self.hass) + self.hass.pool.block_till_done() + + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + def test_if_not_fires_for_enter_on_zone_leave(self): """Test for not firing on zone leave.""" self.hass.states.set('test.entity', 'hello', { diff --git a/tests/components/binary_sensor/test_command_line.py b/tests/components/binary_sensor/test_command_line.py index 758911db353..62b856bbc23 100644 --- a/tests/components/binary_sensor/test_command_line.py +++ b/tests/components/binary_sensor/test_command_line.py @@ -3,6 +3,7 @@ import unittest from homeassistant.const import (STATE_ON, STATE_OFF) from homeassistant.components.binary_sensor import command_line +from homeassistant import bootstrap from tests.common import get_test_home_assistant @@ -24,6 +25,7 @@ class TestCommandSensorBinarySensor(unittest.TestCase): 'command': 'echo 1', 'payload_on': '1', 'payload_off': '0'} + devices = [] def add_dev_callback(devs): @@ -31,8 +33,7 @@ class TestCommandSensorBinarySensor(unittest.TestCase): for dev in devs: devices.append(dev) - command_line.setup_platform( - self.hass, config, add_dev_callback) + command_line.setup_platform(self.hass, config, add_dev_callback) self.assertEqual(1, len(devices)) entity = devices[0] @@ -41,19 +42,13 @@ class TestCommandSensorBinarySensor(unittest.TestCase): def test_setup_bad_config(self): """Test the setup with a bad configuration.""" - config = {} + config = {'name': 'test', + 'platform': 'not_command_line', + } - devices = [] - - def add_dev_callback(devs): - """Add callback to add devices.""" - for dev in devs: - devices.append(dev) - - self.assertFalse(command_line.setup_platform( - self.hass, config, add_dev_callback)) - - self.assertEqual(0, len(devices)) + self.assertFalse(bootstrap.setup_component(self.hass, 'test', { + 'command_line': config, + })) def test_template(self): """Test setting the state with a template.""" diff --git a/tests/components/binary_sensor/test_trend.py b/tests/components/binary_sensor/test_trend.py new file mode 100644 index 00000000000..beb8683e97f --- /dev/null +++ b/tests/components/binary_sensor/test_trend.py @@ -0,0 +1,229 @@ +"""The test for the Trend sensor platform.""" +import homeassistant.bootstrap as bootstrap + +from tests.common import get_test_home_assistant + + +class TestTrendBinarySensor: + """Test the Trend sensor.""" + + def setup_method(self, method): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_up(self): + """Test up trend.""" + assert bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': + "sensor.test_state" + } + } + } + }) + + self.hass.states.set('sensor.test_state', '1') + self.hass.pool.block_till_done() + self.hass.states.set('sensor.test_state', '2') + self.hass.pool.block_till_done() + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'on' + + def test_down(self): + """Test down trend.""" + assert bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': + "sensor.test_state" + } + } + } + }) + + self.hass.states.set('sensor.test_state', '2') + self.hass.pool.block_till_done() + self.hass.states.set('sensor.test_state', '1') + self.hass.pool.block_till_done() + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'off' + + def test__invert_up(self): + """Test up trend with custom message.""" + assert bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': + "sensor.test_state", + 'invert': "Yes" + } + } + } + }) + + self.hass.states.set('sensor.test_state', '1') + self.hass.pool.block_till_done() + self.hass.states.set('sensor.test_state', '2') + self.hass.pool.block_till_done() + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'off' + + def test_invert_down(self): + """Test down trend with custom message.""" + assert bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': + "sensor.test_state", + 'invert': "Yes" + } + } + } + }) + + self.hass.states.set('sensor.test_state', '2') + self.hass.pool.block_till_done() + self.hass.states.set('sensor.test_state', '1') + self.hass.pool.block_till_done() + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'on' + + def test_attribute_up(self): + """Test attribute up trend.""" + assert bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': + "sensor.test_state", + 'attribute': 'attr' + } + } + } + }) + self.hass.states.set('sensor.test_state', 'State', {'attr': '1'}) + self.hass.pool.block_till_done() + self.hass.states.set('sensor.test_state', 'State', {'attr': '2'}) + self.hass.pool.block_till_done() + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'on' + + def test_attribute_down(self): + """Test attribute down trend.""" + assert bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': + "sensor.test_state", + 'attribute': 'attr' + } + } + } + }) + + self.hass.states.set('sensor.test_state', 'State', {'attr': '2'}) + self.hass.pool.block_till_done() + self.hass.states.set('sensor.test_state', 'State', {'attr': '1'}) + + self.hass.pool.block_till_done() + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'off' + + def test_non_numeric(self): + """Test up trend.""" + assert bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': + "sensor.test_state" + } + } + } + }) + + self.hass.states.set('sensor.test_state', 'Non') + self.hass.pool.block_till_done() + self.hass.states.set('sensor.test_state', 'Numeric') + self.hass.pool.block_till_done() + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'off' + + def test_missing_attribute(self): + """Test attribute down trend.""" + assert bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend', + 'sensors': { + 'test_trend_sensor': { + 'entity_id': + "sensor.test_state", + 'attribute': 'missing' + } + } + } + }) + + self.hass.states.set('sensor.test_state', 'State', {'attr': '2'}) + self.hass.pool.block_till_done() + self.hass.states.set('sensor.test_state', 'State', {'attr': '1'}) + + self.hass.pool.block_till_done() + state = self.hass.states.get('binary_sensor.test_trend_sensor') + assert state.state == 'off' + + def test_invalid_name_does_not_create(self): + """Test invalid name.""" + assert not bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'template', + 'sensors': { + 'test INVALID sensor': { + 'entity_id': + "sensor.test_state" + } + } + } + }) + assert self.hass.states.all() == [] + + def test_invalid_sensor_does_not_create(self): + """Test invalid sensor.""" + assert not bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'template', + 'sensors': { + 'test_trend_sensor': { + 'not_entity_id': + "sensor.test_state" + } + } + } + }) + assert self.hass.states.all() == [] + + def test_no_sensors_does_not_create(self): + """Test no sensors.""" + assert not bootstrap.setup_component(self.hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'trend' + } + }) + assert self.hass.states.all() == [] diff --git a/tests/components/camera/test_local_file.py b/tests/components/camera/test_local_file.py index c30f59968e8..546152b0d8a 100644 --- a/tests/components/camera/test_local_file.py +++ b/tests/components/camera/test_local_file.py @@ -59,7 +59,7 @@ class TestLocalCamera(unittest.TestCase): fp.flush() with mock.patch('os.access', return_value=False): - assert setup_component(self.hass, 'camera', { + assert not setup_component(self.hass, 'camera', { 'camera': { 'name': 'config_test', 'platform': 'local_file', diff --git a/tests/components/climate/test_demo.py b/tests/components/climate/test_demo.py index 4b3d4fcc64a..dbb9f8a192e 100644 --- a/tests/components/climate/test_demo.py +++ b/tests/components/climate/test_demo.py @@ -10,6 +10,7 @@ from tests.common import get_test_home_assistant ENTITY_CLIMATE = 'climate.hvac' +ENTITY_ECOBEE = 'climate.ecobee' class TestDemoClimate(unittest.TestCase): @@ -37,7 +38,7 @@ class TestDemoClimate(unittest.TestCase): self.assertEqual(67, state.attributes.get('humidity')) self.assertEqual(54, state.attributes.get('current_humidity')) self.assertEqual("Off", state.attributes.get('swing_mode')) - self.assertEqual("Cool", state.attributes.get('operation_mode')) + self.assertEqual("cool", state.attributes.get('operation_mode')) self.assertEqual('off', state.attributes.get('aux_heat')) def test_default_setup_params(self): @@ -48,7 +49,7 @@ class TestDemoClimate(unittest.TestCase): self.assertEqual(30, state.attributes.get('min_humidity')) self.assertEqual(99, state.attributes.get('max_humidity')) - def test_set_target_temp_bad_attr(self): + def test_set_only_target_temp_bad_attr(self): """Test setting the target temperature without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual(21, state.attributes.get('temperature')) @@ -56,23 +57,55 @@ class TestDemoClimate(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(21, state.attributes.get('temperature')) - def test_set_target_temp(self): + def test_set_only_target_temp(self): """Test the setting of the target temperature.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(21, state.attributes.get('temperature')) climate.set_temperature(self.hass, 30, ENTITY_CLIMATE) self.hass.pool.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual(30.0, state.attributes.get('temperature')) + def test_set_target_temp_range(self): + """Test the setting of the target temperature with range.""" + state = self.hass.states.get(ENTITY_ECOBEE) + self.assertEqual(23.0, state.attributes.get('temperature')) + self.assertEqual(21.0, state.attributes.get('target_temp_low')) + self.assertEqual(24.0, state.attributes.get('target_temp_high')) + climate.set_temperature(self.hass, 30, ENTITY_ECOBEE, 25, 20) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_ECOBEE) + self.assertEqual(30.0, state.attributes.get('temperature')) + self.assertEqual(20.0, state.attributes.get('target_temp_low')) + self.assertEqual(25.0, state.attributes.get('target_temp_high')) + + def test_set_target_temp_range_bad_attr(self): + """Test setting the target temperature range without required + attribute.""" + state = self.hass.states.get(ENTITY_ECOBEE) + self.assertEqual(23, state.attributes.get('temperature')) + self.assertEqual(21.0, state.attributes.get('target_temp_low')) + self.assertEqual(24.0, state.attributes.get('target_temp_high')) + climate.set_temperature(self.hass, None, ENTITY_ECOBEE, None, None) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_ECOBEE) + self.assertEqual(23, state.attributes.get('temperature')) + self.assertEqual(21.0, state.attributes.get('target_temp_low')) + self.assertEqual(24.0, state.attributes.get('target_temp_high')) + def test_set_target_humidity_bad_attr(self): """Test setting the target humidity without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual(67, state.attributes.get('humidity')) climate.set_humidity(self.hass, None, ENTITY_CLIMATE) self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual(67, state.attributes.get('humidity')) def test_set_target_humidity(self): """Test the setting of the target humidity.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(67, state.attributes.get('humidity')) climate.set_humidity(self.hass, 64, ENTITY_CLIMATE) self.hass.pool.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) @@ -84,10 +117,13 @@ class TestDemoClimate(unittest.TestCase): self.assertEqual("On High", state.attributes.get('fan_mode')) climate.set_fan_mode(self.hass, None, ENTITY_CLIMATE) self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual("On High", state.attributes.get('fan_mode')) def test_set_fan_mode(self): """Test setting of new fan mode.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("On High", state.attributes.get('fan_mode')) climate.set_fan_mode(self.hass, "On Low", ENTITY_CLIMATE) self.hass.pool.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) @@ -99,30 +135,40 @@ class TestDemoClimate(unittest.TestCase): self.assertEqual("Off", state.attributes.get('swing_mode')) climate.set_swing_mode(self.hass, None, ENTITY_CLIMATE) self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual("Off", state.attributes.get('swing_mode')) def test_set_swing(self): """Test setting of new swing mode.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("Off", state.attributes.get('swing_mode')) climate.set_swing_mode(self.hass, "Auto", ENTITY_CLIMATE) self.hass.pool.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual("Auto", state.attributes.get('swing_mode')) - def test_set_operation_bad_attr(self): - """Test setting operation mode without required attribute.""" + def test_set_operation_bad_attr_and_state(self): + """Test setting operation mode without required attribute, and + check the state.""" state = self.hass.states.get(ENTITY_CLIMATE) - self.assertEqual("Cool", state.attributes.get('operation_mode')) + self.assertEqual("cool", state.attributes.get('operation_mode')) + self.assertEqual("cool", state.state) climate.set_operation_mode(self.hass, None, ENTITY_CLIMATE) self.hass.pool.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) - self.assertEqual("Cool", state.attributes.get('operation_mode')) + self.assertEqual("cool", state.attributes.get('operation_mode')) + self.assertEqual("cool", state.state) def test_set_operation(self): """Test setting of new operation mode.""" - climate.set_operation_mode(self.hass, "Heat", ENTITY_CLIMATE) + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("cool", state.attributes.get('operation_mode')) + self.assertEqual("cool", state.state) + climate.set_operation_mode(self.hass, "heat", ENTITY_CLIMATE) self.hass.pool.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) - self.assertEqual("Heat", state.attributes.get('operation_mode')) + self.assertEqual("heat", state.attributes.get('operation_mode')) + self.assertEqual("heat", state.state) def test_set_away_mode_bad_attr(self): """Test setting the away mode without required attribute.""" diff --git a/tests/components/climate/test_honeywell.py b/tests/components/climate/test_honeywell.py index 6c97b65dea7..75a4d1081f3 100644 --- a/tests/components/climate/test_honeywell.py +++ b/tests/components/climate/test_honeywell.py @@ -274,7 +274,7 @@ class TestHoneywellRound(unittest.TestCase): def test_set_temperature(self): """Test setting the temperature.""" - self.round1.set_temperature(25) + self.round1.set_temperature(temperature=25) self.device.set_temperature.assert_called_once_with('House', 25) def test_set_operation_mode(self: unittest.TestCase) -> None: @@ -327,13 +327,13 @@ class TestHoneywellUS(unittest.TestCase): def test_set_temp(self): """Test setting the temperature.""" - self.honeywell.set_temperature(70) + self.honeywell.set_temperature(temperature=70) self.assertEqual(70, self.device.setpoint_heat) self.assertEqual(70, self.honeywell.target_temperature) self.device.system_mode = 'cool' self.assertEqual(78, self.honeywell.target_temperature) - self.honeywell.set_temperature(74) + self.honeywell.set_temperature(temperature=74) self.assertEqual(74, self.device.setpoint_cool) self.assertEqual(74, self.honeywell.target_temperature) @@ -351,7 +351,7 @@ class TestHoneywellUS(unittest.TestCase): """Test if setting the temperature fails.""" self.device.setpoint_heat = mock.MagicMock( side_effect=somecomfort.SomeComfortError) - self.honeywell.set_temperature(123) + self.honeywell.set_temperature(temperature=123) def test_attributes(self): """Test the attributes.""" diff --git a/tests/components/cover/test_command_line.py b/tests/components/cover/test_command_line.py index bab0137f4f8..e4ef6793127 100644 --- a/tests/components/cover/test_command_line.py +++ b/tests/components/cover/test_command_line.py @@ -17,12 +17,10 @@ class TestCommandCover(unittest.TestCase): def setup_method(self, method): """Setup things to be run when tests are started.""" self.hass = ha.HomeAssistant() - self.hass.config.latitude = 32.87336 - self.hass.config.longitude = 117.22743 self.rs = cmd_rs.CommandCover(self.hass, 'foo', - 'cmd_open', 'cmd_close', - 'cmd_stop', 'cmd_state', - None) # FIXME + 'command_open', 'command_close', + 'command_stop', 'command_state', + None) def teardown_method(self, method): """Stop down everything that was started.""" @@ -47,10 +45,10 @@ class TestCommandCover(unittest.TestCase): with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, 'cover_status') test_cover = { - 'statecmd': 'cat {}'.format(path), - 'opencmd': 'echo 1 > {}'.format(path), - 'closecmd': 'echo 1 > {}'.format(path), - 'stopcmd': 'echo 0 > {}'.format(path), + 'command_state': 'cat {}'.format(path), + 'command_open': 'echo 1 > {}'.format(path), + 'command_close': 'echo 1 > {}'.format(path), + 'command_stop': 'echo 0 > {}'.format(path), 'value_template': '{{ value }}' } self.assertTrue(cover.setup(self.hass, { diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index fc03426a7a1..a4d5ee64b32 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -138,7 +138,7 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) asuswrt.ssh_connection() ssh.login.assert_called_once_with('fake_host', 'fake_user', - 'fake_pass') + password='fake_pass') def test_ssh_login_without_password_or_pubkey(self): \ # pylint: disable=invalid-name diff --git a/tests/components/device_tracker/test_automatic.py b/tests/components/device_tracker/test_automatic.py new file mode 100644 index 00000000000..e026d91a43c --- /dev/null +++ b/tests/components/device_tracker/test_automatic.py @@ -0,0 +1,254 @@ +"""Test the automatic device tracker platform.""" + +import logging +import requests +import unittest +from unittest.mock import patch + +from homeassistant.components.device_tracker.automatic import ( + URL_AUTHORIZE, URL_VEHICLES, URL_TRIPS, setup_scanner, + AutomaticDeviceScanner) + +_LOGGER = logging.getLogger(__name__) + +INVALID_USERNAME = 'bob' +VALID_USERNAME = 'jim' +PASSWORD = 'password' +CLIENT_ID = '12345' +CLIENT_SECRET = '54321' +FUEL_LEVEL = 77.2 +LATITUDE = 32.82336 +LONGITUDE = -117.23743 +ACCURACY = 8 +DISPLAY_NAME = 'My Vehicle' + + +def mocked_requests(*args, **kwargs): + """Mock requests.get invocations.""" + class MockResponse: + """Class to represent a mocked response.""" + + def __init__(self, json_data, status_code): + """Initialize the mock response class.""" + self.json_data = json_data + self.status_code = status_code + + def json(self): + """Return the json of the response.""" + return self.json_data + + @property + def content(self): + """Return the content of the response.""" + return self.json() + + def raise_for_status(self): + """Raise an HTTPError if status is not 200.""" + if self.status_code != 200: + raise requests.HTTPError(self.status_code) + + data = kwargs.get('data') + + if data and data.get('username', None) == INVALID_USERNAME: + return MockResponse({ + "error": "invalid_credentials" + }, 401) + elif str(args[0]).startswith(URL_AUTHORIZE): + return MockResponse({ + "user": { + "sid": "sid", + "id": "id" + }, + "token_type": "Bearer", + "access_token": "accesstoken", + "refresh_token": "refreshtoken", + "expires_in": 31521669, + "scope": "" + }, 200) + elif str(args[0]).startswith(URL_VEHICLES): + return MockResponse({ + "_metadata": { + "count": 2, + "next": None, + "previous": None + }, + "results": [ + { + "url": "https://api.automatic.com/vehicle/vid/", + "id": "vid", + "created_at": "2016-03-05T20:05:16.240000Z", + "updated_at": "2016-08-29T01:52:59.597898Z", + "make": "Honda", + "model": "Element", + "year": 2007, + "submodel": "EX", + "display_name": DISPLAY_NAME, + "fuel_grade": "regular", + "fuel_level_percent": FUEL_LEVEL, + "active_dtcs": [] + }] + }, 200) + elif str(args[0]).startswith(URL_TRIPS): + return MockResponse({ + "_metadata": { + "count": 1594, + "next": "https://api.automatic.com/trip/?page=2", + "previous": None + }, + "results": [ + { + "url": "https://api.automatic.com/trip/tid1/", + "id": "tid1", + "driver": "https://api.automatic.com/user/uid/", + "user": "https://api.automatic.com/user/uid/", + "started_at": "2016-08-28T19:37:23.986000Z", + "ended_at": "2016-08-28T19:43:22.500000Z", + "distance_m": 3931.6, + "duration_s": 358.5, + "vehicle": "https://api.automatic.com/vehicle/vid/", + "start_location": { + "lat": 32.87336, + "lon": -117.22743, + "accuracy_m": 10 + }, + "start_address": { + "name": "123 Fake St, Nowhere, NV 12345", + "display_name": "123 Fake St, Nowhere, NV", + "street_number": "Unknown", + "street_name": "Fake St", + "city": "Nowhere", + "state": "NV", + "country": "US" + }, + "end_location": { + "lat": LATITUDE, + "lon": LONGITUDE, + "accuracy_m": ACCURACY + }, + "end_address": { + "name": "321 Fake St, Nowhere, NV 12345", + "display_name": "321 Fake St, Nowhere, NV", + "street_number": "Unknown", + "street_name": "Fake St", + "city": "Nowhere", + "state": "NV", + "country": "US" + }, + "path": "path", + "vehicle_events": [], + "start_timezone": "America/Denver", + "end_timezone": "America/Denver", + "idling_time_s": 0, + "tags": [] + }, + { + "url": "https://api.automatic.com/trip/tid2/", + "id": "tid2", + "driver": "https://api.automatic.com/user/uid/", + "user": "https://api.automatic.com/user/uid/", + "started_at": "2016-08-28T18:48:00.727000Z", + "ended_at": "2016-08-28T18:55:25.800000Z", + "distance_m": 3969.1, + "duration_s": 445.1, + "vehicle": "https://api.automatic.com/vehicle/vid/", + "start_location": { + "lat": 32.87336, + "lon": -117.22743, + "accuracy_m": 11 + }, + "start_address": { + "name": "123 Fake St, Nowhere, NV, USA", + "display_name": "Fake St, Nowhere, NV", + "street_number": "123", + "street_name": "Fake St", + "city": "Nowhere", + "state": "NV", + "country": "US" + }, + "end_location": { + "lat": 32.82336, + "lon": -117.23743, + "accuracy_m": 10 + }, + "end_address": { + "name": "321 Fake St, Nowhere, NV, USA", + "display_name": "Fake St, Nowhere, NV", + "street_number": "Unknown", + "street_name": "Fake St", + "city": "Nowhere", + "state": "NV", + "country": "US" + }, + "path": "path", + "vehicle_events": [], + "start_timezone": "America/Denver", + "end_timezone": "America/Denver", + "idling_time_s": 0, + "tags": [] + } + ] + }, 200) + else: + _LOGGER.debug('UNKNOWN ROUTE') + + +class TestAutomatic(unittest.TestCase): + """Test cases around the automatic device scanner.""" + + def see_mock(self, **kwargs): + """Mock see function.""" + self.assertEqual('vid', kwargs.get('dev_id')) + self.assertEqual(FUEL_LEVEL, + kwargs.get('attributes', {}).get('fuel_level')) + self.assertEqual((LATITUDE, LONGITUDE), kwargs.get('gps')) + self.assertEqual(ACCURACY, kwargs.get('gps_accuracy')) + + def setUp(self): + """Set up test data.""" + + def tearDown(self): + """Tear down test data.""" + + @patch('requests.get', side_effect=mocked_requests) + @patch('requests.post', side_effect=mocked_requests) + def test_invalid_credentials(self, mock_get, mock_post): + """Test error is raised with invalid credentials.""" + config = { + 'platform': 'automatic', + 'username': INVALID_USERNAME, + 'password': PASSWORD, + 'client_id': CLIENT_ID, + 'secret': CLIENT_SECRET + } + + self.assertFalse(setup_scanner(None, config, self.see_mock)) + + @patch('requests.get', side_effect=mocked_requests) + @patch('requests.post', side_effect=mocked_requests) + def test_valid_credentials(self, mock_get, mock_post): + """Test error is raised with invalid credentials.""" + config = { + 'platform': 'automatic', + 'username': VALID_USERNAME, + 'password': PASSWORD, + 'client_id': CLIENT_ID, + 'secret': CLIENT_SECRET + } + + self.assertTrue(setup_scanner(None, config, self.see_mock)) + + @patch('requests.get', side_effect=mocked_requests) + @patch('requests.post', side_effect=mocked_requests) + def test_device_attributes(self, mock_get, mock_post): + """Test device attributes are set on load.""" + config = { + 'platform': 'automatic', + 'username': VALID_USERNAME, + 'password': PASSWORD, + 'client_id': CLIENT_ID, + 'secret': CLIENT_SECRET + } + + scanner = AutomaticDeviceScanner(config, self.see_mock) + + self.assertEqual(DISPLAY_NAME, scanner.get_device_name('vid')) diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 393b61a3134..57125d6e6ea 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -17,6 +17,10 @@ DEVICE = 'phone' LOCATION_TOPIC = "owntracks/{}/{}".format(USER, DEVICE) EVENT_TOPIC = "owntracks/{}/{}/event".format(USER, DEVICE) +WAYPOINT_TOPIC = owntracks.WAYPOINT_TOPIC.format(USER, DEVICE) +USER_BLACKLIST = 'ram' +WAYPOINT_TOPIC_BLOCKED = owntracks.WAYPOINT_TOPIC.format(USER_BLACKLIST, + DEVICE) DEVICE_TRACKER_STATE = "device_tracker.{}_{}".format(USER, DEVICE) @@ -24,6 +28,8 @@ IBEACON_DEVICE = 'keys' REGION_TRACKER_STATE = "device_tracker.beacon_{}".format(IBEACON_DEVICE) CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' +CONF_WAYPOINT_IMPORT = owntracks.CONF_WAYPOINT_IMPORT +CONF_WAYPOINT_WHITELIST = owntracks.CONF_WAYPOINT_WHITELIST LOCATION_MESSAGE = { 'batt': 92, @@ -107,6 +113,48 @@ REGION_LEAVE_INACCURATE_MESSAGE = { 'lat': 20.0, '_type': 'transition'} +WAYPOINTS_EXPORTED_MESSAGE = { + "_type": "waypoints", + "_creator": "test", + "waypoints": [ + { + "_type": "waypoint", + "tst": 3, + "lat": 47, + "lon": 9, + "rad": 10, + "desc": "exp_wayp1" + }, + { + "_type": "waypoint", + "tst": 4, + "lat": 3, + "lon": 9, + "rad": 500, + "desc": "exp_wayp2" + } + ] +} + +WAYPOINTS_UPDATED_MESSAGE = { + "_type": "waypoints", + "_creator": "test", + "waypoints": [ + { + "_type": "waypoint", + "tst": 4, + "lat": 9, + "lon": 47, + "rad": 50, + "desc": "exp_wayp1" + }, + ] +} + +WAYPOINT_ENTITY_NAMES = ['zone.greg_phone__exp_wayp1', + 'zone.greg_phone__exp_wayp2', + 'zone.ram_phone__exp_wayp1', + 'zone.ram_phone__exp_wayp2'] REGION_ENTER_ZERO_MESSAGE = { 'lon': 1.0, @@ -132,6 +180,9 @@ REGION_LEAVE_ZERO_MESSAGE = { 'lat': 20.0, '_type': 'transition'} +BAD_JSON_PREFIX = '--$this is bad json#--' +BAD_JSON_SUFFIX = '** and it ends here ^^' + class TestDeviceTrackerOwnTracks(unittest.TestCase): """Test the OwnTrack sensor.""" @@ -143,7 +194,9 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): self.assertTrue(device_tracker.setup(self.hass, { device_tracker.DOMAIN: { CONF_PLATFORM: 'owntracks', - CONF_MAX_GPS_ACCURACY: 200 + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT: True, + CONF_WAYPOINT_WHITELIST: ['jon', 'greg'] }})) self.hass.states.set( @@ -187,10 +240,18 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): except FileNotFoundError: pass - def send_message(self, topic, message): + def mock_see(**kwargs): + """Fake see method for owntracks.""" + return + + def send_message(self, topic, message, corrupt=False): """Test the sending of a message.""" - fire_mqtt_message( - self.hass, topic, json.dumps(message)) + str_message = json.dumps(message) + if corrupt: + mod_message = BAD_JSON_PREFIX + str_message + BAD_JSON_SUFFIX + else: + mod_message = str_message + fire_mqtt_message(self.hass, topic, mod_message) self.hass.pool.block_till_done() def assert_location_state(self, location): @@ -530,3 +591,61 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): self.send_message(EVENT_TOPIC, exit_message) self.assertEqual(owntracks.MOBILE_BEACONS_ACTIVE['greg_phone'], []) + + def test_waypoint_import_simple(self): + """Test a simple import of list of waypoints.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + self.send_message(WAYPOINT_TOPIC, waypoints_message) + # Check if it made it into states + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + self.assertTrue(wayp is not None) + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[1]) + self.assertTrue(wayp is not None) + + def test_waypoint_import_blacklist(self): + """Test import of list of waypoints for blacklisted user.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + self.send_message(WAYPOINT_TOPIC_BLOCKED, waypoints_message) + # Check if it made it into states + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + self.assertTrue(wayp is None) + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + self.assertTrue(wayp is None) + + def test_waypoint_import_no_whitelist(self): + """Test import of list of waypoints with no whitelist set.""" + test_config = { + CONF_PLATFORM: 'owntracks', + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT: True + } + owntracks.setup_scanner(self.hass, test_config, self.mock_see) + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + self.send_message(WAYPOINT_TOPIC_BLOCKED, waypoints_message) + # Check if it made it into states + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + self.assertTrue(wayp is not None) + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + self.assertTrue(wayp is not None) + + def test_waypoint_import_bad_json(self): + """Test importing a bad JSON payload.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + self.send_message(WAYPOINT_TOPIC, waypoints_message, True) + # Check if it made it into states + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + self.assertTrue(wayp is None) + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + self.assertTrue(wayp is None) + + def test_waypoint_import_existing(self): + """Test importing a zone that exists.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + self.send_message(WAYPOINT_TOPIC, waypoints_message) + # Get the first waypoint exported + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + # Send an update + waypoints_message = WAYPOINTS_UPDATED_MESSAGE.copy() + self.send_message(WAYPOINT_TOPIC, waypoints_message) + new_wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + self.assertTrue(wayp == new_wayp) diff --git a/tests/components/device_tracker/test_unifi.py b/tests/components/device_tracker/test_unifi.py index e3f64cc84c3..8e43eb7485e 100644 --- a/tests/components/device_tracker/test_unifi.py +++ b/tests/components/device_tracker/test_unifi.py @@ -3,9 +3,12 @@ import unittest from unittest import mock import urllib -from homeassistant.components.device_tracker import unifi as unifi -from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD from unifi import controller +import voluptuous as vol + +from homeassistant.components.device_tracker import DOMAIN, unifi as unifi +from homeassistant.const import (CONF_HOST, CONF_USERNAME, CONF_PASSWORD, + CONF_PLATFORM) class TestUnifiScanner(unittest.TestCase): @@ -16,13 +19,14 @@ class TestUnifiScanner(unittest.TestCase): def test_config_minimal(self, mock_ctrl, mock_scanner): """Test the setup with minimal configuration.""" config = { - 'device_tracker': { + DOMAIN: unifi.PLATFORM_SCHEMA({ + CONF_PLATFORM: unifi.DOMAIN, CONF_USERNAME: 'foo', CONF_PASSWORD: 'password', - } + }) } result = unifi.get_scanner(None, config) - self.assertEqual(unifi.UnifiScanner.return_value, result) + self.assertEqual(mock_scanner.return_value, result) mock_ctrl.assert_called_once_with('localhost', 'foo', 'password', 8443, 'v4', 'default') mock_scanner.assert_called_once_with(mock_ctrl.return_value) @@ -32,49 +36,38 @@ class TestUnifiScanner(unittest.TestCase): def test_config_full(self, mock_ctrl, mock_scanner): """Test the setup with full configuration.""" config = { - 'device_tracker': { + DOMAIN: unifi.PLATFORM_SCHEMA({ + CONF_PLATFORM: unifi.DOMAIN, CONF_USERNAME: 'foo', CONF_PASSWORD: 'password', CONF_HOST: 'myhost', 'port': 123, 'site_id': 'abcdef01', - } + }) } result = unifi.get_scanner(None, config) - self.assertEqual(unifi.UnifiScanner.return_value, result) + self.assertEqual(mock_scanner.return_value, result) mock_ctrl.assert_called_once_with('myhost', 'foo', 'password', 123, 'v4', 'abcdef01') mock_scanner.assert_called_once_with(mock_ctrl.return_value) - @mock.patch('homeassistant.components.device_tracker.unifi.UnifiScanner') - @mock.patch.object(controller, 'Controller') - def test_config_error(self, mock_ctrl, mock_scanner): + def test_config_error(self): """Test for configuration errors.""" - config = { - 'device_tracker': { + with self.assertRaises(vol.Invalid): + unifi.PLATFORM_SCHEMA({ + # no username + CONF_PLATFORM: unifi.DOMAIN, CONF_HOST: 'myhost', 'port': 123, - } - } - result = unifi.get_scanner(None, config) - self.assertFalse(result) - self.assertFalse(mock_ctrl.called) - - @mock.patch('homeassistant.components.device_tracker.unifi.UnifiScanner') - @mock.patch.object(controller, 'Controller') - def test_config_badport(self, mock_ctrl, mock_scanner): - """Test the setup with a bad port.""" - config = { - 'device_tracker': { + }) + with self.assertRaises(vol.Invalid): + unifi.PLATFORM_SCHEMA({ + CONF_PLATFORM: unifi.DOMAIN, CONF_USERNAME: 'foo', CONF_PASSWORD: 'password', CONF_HOST: 'myhost', - 'port': 'foo', - } - } - result = unifi.get_scanner(None, config) - self.assertFalse(result) - self.assertFalse(mock_ctrl.called) + 'port': 'foo', # bad port! + }) @mock.patch('homeassistant.components.device_tracker.unifi.UnifiScanner') @mock.patch.object(controller, 'Controller') @@ -82,6 +75,7 @@ class TestUnifiScanner(unittest.TestCase): """Test for controller failure.""" config = { 'device_tracker': { + CONF_PLATFORM: unifi.DOMAIN, CONF_USERNAME: 'foo', CONF_PASSWORD: 'password', } @@ -91,7 +85,7 @@ class TestUnifiScanner(unittest.TestCase): result = unifi.get_scanner(None, config) self.assertFalse(result) - def test_scanner_update(self): + def test_scanner_update(self): # pylint: disable=no-self-use """Test the scanner update.""" ctrl = mock.MagicMock() fake_clients = [ @@ -102,7 +96,7 @@ class TestUnifiScanner(unittest.TestCase): unifi.UnifiScanner(ctrl) ctrl.get_clients.assert_called_once_with() - def test_scanner_update_error(self): + def test_scanner_update_error(self): # pylint: disable=no-self-use """Test the scanner update for error.""" ctrl = mock.MagicMock() ctrl.get_clients.side_effect = urllib.error.HTTPError( diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 4c5f14bf0f1..3678585141d 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -149,7 +149,7 @@ class TestMQTT(unittest.TestCase): def test_subscribe_topic(self): """Test the subscription of a topic.""" - mqtt.subscribe(self.hass, 'test-topic', self.record_calls) + unsub = mqtt.subscribe(self.hass, 'test-topic', self.record_calls) fire_mqtt_message(self.hass, 'test-topic', 'test-payload') @@ -158,6 +158,13 @@ class TestMQTT(unittest.TestCase): self.assertEqual('test-topic', self.calls[0][0]) self.assertEqual('test-payload', self.calls[0][1]) + unsub() + + fire_mqtt_message(self.hass, 'test-topic', 'test-payload') + + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_subscribe_topic_not_match(self): """Test if subscribed topic is not a match.""" mqtt.subscribe(self.hass, 'test-topic', self.record_calls) diff --git a/tests/components/notify/test_command_line.py b/tests/components/notify/test_command_line.py index ffe156deb9d..d350b0e4b37 100644 --- a/tests/components/notify/test_command_line.py +++ b/tests/components/notify/test_command_line.py @@ -2,13 +2,12 @@ import os import tempfile import unittest +from unittest.mock import patch import homeassistant.components.notify as notify - +from homeassistant import bootstrap from tests.common import get_test_home_assistant -from unittest.mock import patch - class TestCommandLine(unittest.TestCase): """Test the command line notifications.""" @@ -21,20 +20,23 @@ class TestCommandLine(unittest.TestCase): """Stop down everything that was started.""" self.hass.stop() + def test_setup(self): + """Test setup.""" + assert bootstrap.setup_component(self.hass, 'notify', { + 'notify': { + 'name': 'test', + 'platform': 'command_line', + 'command': 'echo $(cat); exit 1', + }}) + def test_bad_config(self): - """Test set up the platform with bad/missing config.""" + """Test set up the platform with bad/missing configuration.""" self.assertFalse(notify.setup(self.hass, { 'notify': { 'name': 'test', 'platform': 'bad_platform', } })) - self.assertFalse(notify.setup(self.hass, { - 'notify': { - 'name': 'test', - 'platform': 'command_line', - } - })) def test_command_line_output(self): """Test the command line output.""" diff --git a/tests/components/notify/test_demo.py b/tests/components/notify/test_demo.py index f0a05a01c1f..6f0daeaf7b8 100644 --- a/tests/components/notify/test_demo.py +++ b/tests/components/notify/test_demo.py @@ -68,7 +68,7 @@ class TestNotifyDemo(unittest.TestCase): 'data': {'hello': 'world'} } == data - def test_calling_notify_from_script_loaded_from_yaml(self): + def test_calling_notify_from_script_loaded_from_yaml_without_title(self): """Test if we can call a notify from a script.""" yaml_conf = """ service: notify.notify @@ -92,7 +92,38 @@ data_template: assert { 'message': 'Test 123 4', 'target': None, - 'title': 'Home Assistant', + 'data': { + 'push': { + 'sound': + 'US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav'}} + } == self.events[0].data + + def test_calling_notify_from_script_loaded_from_yaml_with_title(self): + """Test if we can call a notify from a script.""" + yaml_conf = """ +service: notify.notify +data: + data: + push: + sound: US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav +data_template: + title: Test + message: > + Test 123 {{ 2 + 2 }} +""" + + with tempfile.NamedTemporaryFile() as fp: + fp.write(yaml_conf.encode('utf-8')) + fp.flush() + conf = yaml.load_yaml(fp.name) + + script.call_from_config(self.hass, conf) + self.hass.pool.block_till_done() + self.assertTrue(len(self.events) == 1) + assert { + 'message': 'Test 123 4', + 'title': 'Test', + 'target': None, 'data': { 'push': { 'sound': diff --git a/tests/components/notify/test_file.py b/tests/components/notify/test_file.py index 87d275b05f9..2564b0bd65a 100644 --- a/tests/components/notify/test_file.py +++ b/tests/components/notify/test_file.py @@ -8,6 +8,7 @@ import homeassistant.components.notify as notify from homeassistant.components.notify import ( ATTR_TITLE_DEFAULT) import homeassistant.util.dt as dt_util +from homeassistant.bootstrap import _setup_component from tests.common import get_test_home_assistant @@ -25,11 +26,11 @@ class TestNotifyFile(unittest.TestCase): def test_bad_config(self): """Test set up the platform with bad/missing config.""" - self.assertFalse(notify.setup(self.hass, { + self.assertFalse(_setup_component(self.hass, notify.DOMAIN, { 'notify': { 'name': 'test', 'platform': 'file', - } + }, })) @patch('homeassistant.util.dt.utcnow') @@ -45,7 +46,7 @@ class TestNotifyFile(unittest.TestCase): 'name': 'test', 'platform': 'file', 'filename': filename, - 'timestamp': 0 + 'timestamp': False, } })) title = '{} notifications (Log started: {})\n{}\n'.format( diff --git a/tests/components/notify/test_smtp.py b/tests/components/notify/test_smtp.py index 7fa61fbdc24..bbaca71ee13 100644 --- a/tests/components/notify/test_smtp.py +++ b/tests/components/notify/test_smtp.py @@ -37,7 +37,7 @@ class TestNotifySmtp(unittest.TestCase): expected = ('Content-Type: text/plain; charset="us-ascii"\n' 'MIME-Version: 1.0\n' 'Content-Transfer-Encoding: 7bit\n' - 'Subject: \n' + 'Subject: Home Assistant\n' 'To: testrecip@test.com\n' 'From: test@test.com\n' 'X-Mailer: HomeAssistant\n' diff --git a/tests/components/sensor/test_command_line.py b/tests/components/sensor/test_command_line.py index bd083f7b63e..b089a82356b 100644 --- a/tests/components/sensor/test_command_line.py +++ b/tests/components/sensor/test_command_line.py @@ -2,7 +2,7 @@ import unittest from homeassistant.components.sensor import command_line - +from homeassistant import bootstrap from tests.common import get_test_home_assistant @@ -21,7 +21,8 @@ class TestCommandSensorSensor(unittest.TestCase): """Test sensor setup.""" config = {'name': 'Test', 'unit_of_measurement': 'in', - 'command': 'echo 5'} + 'command': 'echo 5' + } devices = [] def add_dev_callback(devs): @@ -29,8 +30,7 @@ class TestCommandSensorSensor(unittest.TestCase): for dev in devs: devices.append(dev) - command_line.setup_platform( - self.hass, config, add_dev_callback) + command_line.setup_platform(self.hass, config, add_dev_callback) self.assertEqual(1, len(devices)) entity = devices[0] @@ -40,19 +40,13 @@ class TestCommandSensorSensor(unittest.TestCase): def test_setup_bad_config(self): """Test setup with a bad configuration.""" - config = {} + config = {'name': 'test', + 'platform': 'not_command_line', + } - devices = [] - - def add_dev_callback(devs): - """Add a callback to add devices.""" - for dev in devs: - devices.append(dev) - - self.assertFalse(command_line.setup_platform( - self.hass, config, add_dev_callback)) - - self.assertEqual(0, len(devices)) + self.assertFalse(bootstrap.setup_component(self.hass, 'test', { + 'command_line': config, + })) def test_template(self): """Test command sensor with template.""" diff --git a/tests/components/sensor/test_mfi.py b/tests/components/sensor/test_mfi.py index 8180ca152f3..c1e6ac899ec 100644 --- a/tests/components/sensor/test_mfi.py +++ b/tests/components/sensor/test_mfi.py @@ -24,16 +24,14 @@ class TestMfiSensorSetup(unittest.TestCase): 'port': 6123, 'username': 'user', 'password': 'pass', - 'use_tls': True, - 'verify_tls': True, + 'ssl': True, + 'verify_ssl': True, } } def setup_method(self, method): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.latitude = 32.87336 - self.hass.config.longitude = 117.22743 def teardown_method(self, method): """Stop everything that was started.""" @@ -54,9 +52,8 @@ class TestMfiSensorSetup(unittest.TestCase): mock_client.FailedToLogin = Exception() mock_client.MFiClient.side_effect = mock_client.FailedToLogin self.assertFalse( - self.PLATFORM.setup_platform(self.hass, - dict(self.GOOD_CONFIG), - None)) + self.PLATFORM.setup_platform( + self.hass, dict(self.GOOD_CONFIG), None)) @mock.patch('mficlient.client') def test_setup_failed_connect(self, mock_client): @@ -64,9 +61,8 @@ class TestMfiSensorSetup(unittest.TestCase): mock_client.FailedToLogin = Exception() mock_client.MFiClient.side_effect = requests.exceptions.ConnectionError self.assertFalse( - self.PLATFORM.setup_platform(self.hass, - dict(self.GOOD_CONFIG), - None)) + self.PLATFORM.setup_platform( + self.hass, dict(self.GOOD_CONFIG), None)) @mock.patch('mficlient.client.MFiClient') def test_setup_minimum(self, mock_client): @@ -74,9 +70,8 @@ class TestMfiSensorSetup(unittest.TestCase): config = dict(self.GOOD_CONFIG) del config[self.THING]['port'] assert self.COMPONENT.setup(self.hass, config) - mock_client.assert_called_once_with('foo', 'user', 'pass', - port=6443, use_tls=True, - verify=True) + mock_client.assert_called_once_with( + 'foo', 'user', 'pass', port=6443, use_tls=True, verify=True) @mock.patch('mficlient.client.MFiClient') def test_setup_with_port(self, mock_client): @@ -84,21 +79,19 @@ class TestMfiSensorSetup(unittest.TestCase): config = dict(self.GOOD_CONFIG) config[self.THING]['port'] = 6123 assert self.COMPONENT.setup(self.hass, config) - mock_client.assert_called_once_with('foo', 'user', 'pass', - port=6123, use_tls=True, - verify=True) + mock_client.assert_called_once_with( + 'foo', 'user', 'pass', port=6123, use_tls=True, verify=True) @mock.patch('mficlient.client.MFiClient') def test_setup_with_tls_disabled(self, mock_client): """Test setup without TLS.""" config = dict(self.GOOD_CONFIG) del config[self.THING]['port'] - config[self.THING]['use_tls'] = False - config[self.THING]['verify_tls'] = False + config[self.THING]['ssl'] = False + config[self.THING]['verify_ssl'] = False assert self.COMPONENT.setup(self.hass, config) - mock_client.assert_called_once_with('foo', 'user', 'pass', - port=6080, use_tls=False, - verify=False) + mock_client.assert_called_once_with( + 'foo', 'user', 'pass', port=6080, use_tls=False, verify=False) @mock.patch('mficlient.client.MFiClient') @mock.patch('homeassistant.components.sensor.mfi.MfiSensor') @@ -123,8 +116,6 @@ class TestMfiSensor(unittest.TestCase): def setup_method(self, method): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.latitude = 32.87336 - self.hass.config.longitude = 117.22743 self.port = mock.MagicMock() self.sensor = mfi.MfiSensor(self.port, self.hass) @@ -156,6 +147,11 @@ class TestMfiSensor(unittest.TestCase): self.port.tag = 'balloons' self.assertEqual('balloons', self.sensor.unit_of_measurement) + def test_uom_uninitialized(self): + """Test that the UOM defaults if not initialized.""" + type(self.port).tag = mock.PropertyMock(side_effect=ValueError) + self.assertEqual('State', self.sensor.unit_of_measurement) + def test_state_digital(self): """Test the digital input.""" self.port.model = 'Input Digital' @@ -175,6 +171,11 @@ class TestMfiSensor(unittest.TestCase): with mock.patch.dict(mfi.DIGITS, {}): self.assertEqual(1.0, self.sensor.state) + def test_state_uninitialized(self): + """Test the state of uninitialized sensors.""" + type(self.port).tag = mock.PropertyMock(side_effect=ValueError) + self.assertEqual(mfi.STATE_OFF, self.sensor.state) + def test_update(self): """Test the update.""" self.sensor.update() diff --git a/tests/components/sensor/test_moldindicator.py b/tests/components/sensor/test_moldindicator.py index da2798e2a4d..c634c043db5 100644 --- a/tests/components/sensor/test_moldindicator.py +++ b/tests/components/sensor/test_moldindicator.py @@ -36,7 +36,7 @@ class TestSensorMoldIndicator(unittest.TestCase): 'indoor_temp_sensor': 'test.indoortemp', 'outdoor_temp_sensor': 'test.outdoortemp', 'indoor_humidity_sensor': 'test.indoorhumidity', - 'calibration_factor': '2.0' + 'calibration_factor': 2.0 } })) @@ -59,13 +59,11 @@ class TestSensorMoldIndicator(unittest.TestCase): 'indoor_temp_sensor': 'test.indoortemp', 'outdoor_temp_sensor': 'test.outdoortemp', 'indoor_humidity_sensor': 'test.indoorhumidity', - 'calibration_factor': '2.0' + 'calibration_factor': 2.0 } })) moldind = self.hass.states.get('sensor.mold_indicator') assert moldind - - # assert state assert moldind.state == '0' def test_calculation(self): @@ -76,7 +74,7 @@ class TestSensorMoldIndicator(unittest.TestCase): 'indoor_temp_sensor': 'test.indoortemp', 'outdoor_temp_sensor': 'test.outdoortemp', 'indoor_humidity_sensor': 'test.indoorhumidity', - 'calibration_factor': '2.0' + 'calibration_factor': 2.0 } })) @@ -108,23 +106,20 @@ class TestSensorMoldIndicator(unittest.TestCase): 'indoor_temp_sensor': 'test.indoortemp', 'outdoor_temp_sensor': 'test.outdoortemp', 'indoor_humidity_sensor': 'test.indoorhumidity', - 'calibration_factor': '2.0' + 'calibration_factor': 2.0 } })) - # Change indoor temp self.hass.states.set('test.indoortemp', '30', {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) self.hass.pool.block_till_done() assert self.hass.states.get('sensor.mold_indicator').state == '90' - # Change outdoor temp self.hass.states.set('test.outdoortemp', '25', {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) self.hass.pool.block_till_done() assert self.hass.states.get('sensor.mold_indicator').state == '57' - # Change humidity self.hass.states.set('test.indoorhumidity', '20', {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) self.hass.pool.block_till_done() diff --git a/tests/components/switch/test_command_line.py b/tests/components/switch/test_command_line.py index f71fb9c25aa..5e17710f8fd 100644 --- a/tests/components/switch/test_command_line.py +++ b/tests/components/switch/test_command_line.py @@ -27,8 +27,8 @@ class TestCommandSwitch(unittest.TestCase): with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, 'switch_status') test_switch = { - 'oncmd': 'echo 1 > {}'.format(path), - 'offcmd': 'echo 0 > {}'.format(path), + 'command_on': 'echo 1 > {}'.format(path), + 'command_off': 'echo 0 > {}'.format(path), } self.assertTrue(switch.setup(self.hass, { 'switch': { @@ -59,9 +59,9 @@ class TestCommandSwitch(unittest.TestCase): with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, 'switch_status') test_switch = { - 'statecmd': 'cat {}'.format(path), - 'oncmd': 'echo 1 > {}'.format(path), - 'offcmd': 'echo 0 > {}'.format(path), + 'command_state': 'cat {}'.format(path), + 'command_on': 'echo 1 > {}'.format(path), + 'command_off': 'echo 0 > {}'.format(path), 'value_template': '{{ value=="1" }}' } self.assertTrue(switch.setup(self.hass, { @@ -95,9 +95,9 @@ class TestCommandSwitch(unittest.TestCase): oncmd = json.dumps({'status': 'ok'}) offcmd = json.dumps({'status': 'nope'}) test_switch = { - 'statecmd': 'cat {}'.format(path), - 'oncmd': 'echo \'{}\' > {}'.format(oncmd, path), - 'offcmd': 'echo \'{}\' > {}'.format(offcmd, path), + 'command_state': 'cat {}'.format(path), + 'command_on': 'echo \'{}\' > {}'.format(oncmd, path), + 'command_off': 'echo \'{}\' > {}'.format(offcmd, path), 'value_template': '{{ value_json.status=="ok" }}' } self.assertTrue(switch.setup(self.hass, { @@ -129,9 +129,9 @@ class TestCommandSwitch(unittest.TestCase): with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, 'switch_status') test_switch = { - 'statecmd': 'cat {}'.format(path), - 'oncmd': 'echo 1 > {}'.format(path), - 'offcmd': 'echo 0 > {}'.format(path), + 'command_state': 'cat {}'.format(path), + 'command_on': 'echo 1 > {}'.format(path), + 'command_off': 'echo 0 > {}'.format(path), } self.assertTrue(switch.setup(self.hass, { 'switch': { diff --git a/tests/components/switch/test_mfi.py b/tests/components/switch/test_mfi.py index 5eccb88f2ca..95a1000cc46 100644 --- a/tests/components/switch/test_mfi.py +++ b/tests/components/switch/test_mfi.py @@ -22,6 +22,8 @@ class TestMfiSwitchSetup(test_mfi_sensor.TestMfiSensorSetup): 'port': 6123, 'username': 'user', 'password': 'pass', + 'ssl': True, + 'verify_ssl': True, } } @@ -48,8 +50,6 @@ class TestMfiSwitch(unittest.TestCase): def setup_method(self, method): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.latitude = 32.87336 - self.hass.config.longitude = 117.22743 self.port = mock.MagicMock() self.switch = mfi.MfiSwitch(self.port) diff --git a/tests/components/test_group.py b/tests/components/test_group.py index d815489ae21..e82190a3f29 100644 --- a/tests/components/test_group.py +++ b/tests/components/test_group.py @@ -1,6 +1,7 @@ """The tests for the Group components.""" # pylint: disable=protected-access,too-many-public-methods import unittest +from unittest.mock import patch from homeassistant.bootstrap import _setup_component from homeassistant.const import ( @@ -308,3 +309,33 @@ class TestComponentsGroup(unittest.TestCase): self.assertEqual(STATE_NOT_HOME, self.hass.states.get( group.ENTITY_ID_FORMAT.format('peeps')).state) + + def test_reloading_groups(self): + """Test reloading the group config.""" + _setup_component(self.hass, 'group', {'group': { + 'second_group': { + 'entities': 'light.Bowl', + 'icon': 'mdi:work', + 'view': True, + }, + 'test_group': 'hello.world,sensor.happy', + 'empty_group': {'name': 'Empty Group', 'entities': None}, + } + }) + + assert sorted(self.hass.states.entity_ids()) == \ + ['group.empty_group', 'group.second_group', 'group.test_group'] + assert self.hass.bus.listeners['state_changed'] == 3 + + with patch('homeassistant.config.load_yaml_config_file', return_value={ + 'group': { + 'hello': { + 'entities': 'light.Bowl', + 'icon': 'mdi:work', + 'view': True, + }}}): + group.reload(self.hass) + self.hass.pool.block_till_done() + + assert self.hass.states.entity_ids() == ['group.hello'] + assert self.hass.bus.listeners['state_changed'] == 1 diff --git a/tests/components/test_weblink.py b/tests/components/test_weblink.py index 70a32e850ed..bb539d902ff 100644 --- a/tests/components/test_weblink.py +++ b/tests/components/test_weblink.py @@ -2,6 +2,7 @@ import unittest from homeassistant.components import weblink +from homeassistant import bootstrap from tests.common import get_test_home_assistant @@ -17,16 +18,23 @@ class TestComponentWeblink(unittest.TestCase): """Stop everything that was started.""" self.hass.stop() + def test_bad_config(self): + """Test if new entity is created.""" + self.assertFalse(bootstrap.setup_component(self.hass, 'weblink', { + 'weblink': { + 'entities': [{}], + } + })) + def test_entities_get_created(self): """Test if new entity is created.""" self.assertTrue(weblink.setup(self.hass, { weblink.DOMAIN: { 'entities': [ { - weblink.ATTR_NAME: 'My router', - weblink.ATTR_URL: 'http://127.0.0.1/' + weblink.CONF_NAME: 'My router', + weblink.CONF_URL: 'http://127.0.0.1/' }, - {} ] } })) diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 14d80d9104d..d9da2c51da7 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1,4 +1,6 @@ from datetime import timedelta +import os +import tempfile import pytest import voluptuous as vol @@ -59,6 +61,24 @@ def test_port(): schema(value) +def test_isfile(): + """Validate that the value is an existing file.""" + schema = vol.Schema(cv.isfile) + + with tempfile.NamedTemporaryFile() as fp: + pass + + for value in ('invalid', None, -1, 0, 80000, fp.name): + with pytest.raises(vol.Invalid): + schema(value) + + with tempfile.TemporaryDirectory() as tmp_path: + tmp_file = os.path.join(tmp_path, "test.txt") + with open(tmp_file, "w") as tmp_handl: + tmp_handl.write("test file") + schema(tmp_file) + + def test_url(): """Test URL.""" schema = vol.Schema(cv.url) @@ -279,9 +299,7 @@ def test_template(): """Test template validator.""" schema = vol.Schema(cv.template) - for value in ( - None, '{{ partial_print }', '{% if True %}Hello', {'dict': 'isbad'} - ): + for value in (None, '{{ partial_print }', '{% if True %}Hello', ['test']): with pytest.raises(vol.MultipleInvalid): schema(value) @@ -293,6 +311,24 @@ def test_template(): schema(value) +def test_template_complex(): + """Test template_complex validator.""" + schema = vol.Schema(cv.template_complex) + + for value in (None, '{{ partial_print }', '{% if True %}Hello'): + with pytest.raises(vol.MultipleInvalid): + schema(value) + + for value in ( + 1, 'Hello', + '{{ beer }}', + '{% if 1 == 1 %}Hello{% else %}World{% endif %}', + {'test': 1, 'test': '{{ beer }}'}, + ['{{ beer }}', 1] + ): + schema(value) + + def test_time_zone(): """Test time zone validation.""" schema = vol.Schema(cv.time_zone) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 5d9f8d28e20..704a501eefc 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -65,13 +65,21 @@ class TestEventHelpers(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(2, len(runs)) + unsub = track_point_in_time( + self.hass, lambda x: runs.append(1), birthday_paulus) + unsub() + + self._send_time_changed(after_birthday) + self.hass.pool.block_till_done() + self.assertEqual(2, len(runs)) + def test_track_time_change(self): """Test tracking time change.""" wildcard_runs = [] specific_runs = [] - track_time_change(self.hass, lambda x: wildcard_runs.append(1)) - track_utc_time_change( + unsub = track_time_change(self.hass, lambda x: wildcard_runs.append(1)) + unsub_utc = track_utc_time_change( self.hass, lambda x: specific_runs.append(1), second=[0, 30]) self._send_time_changed(datetime(2014, 5, 24, 12, 0, 0)) @@ -89,6 +97,14 @@ class TestEventHelpers(unittest.TestCase): self.assertEqual(2, len(specific_runs)) self.assertEqual(3, len(wildcard_runs)) + unsub() + unsub_utc() + + self._send_time_changed(datetime(2014, 5, 24, 12, 0, 30)) + self.hass.pool.block_till_done() + self.assertEqual(2, len(specific_runs)) + self.assertEqual(3, len(wildcard_runs)) + def test_track_state_change(self): """Test track_state_change.""" # 2 lists to track how often our callbacks get called @@ -186,11 +202,12 @@ class TestEventHelpers(unittest.TestCase): # Track sunrise runs = [] - track_sunrise(self.hass, lambda: runs.append(1)) + unsub = track_sunrise(self.hass, lambda: runs.append(1)) offset_runs = [] offset = timedelta(minutes=30) - track_sunrise(self.hass, lambda: offset_runs.append(1), offset) + unsub2 = track_sunrise(self.hass, lambda: offset_runs.append(1), + offset) # run tests self._send_time_changed(next_rising - offset) @@ -208,6 +225,14 @@ class TestEventHelpers(unittest.TestCase): self.assertEqual(2, len(runs)) self.assertEqual(1, len(offset_runs)) + unsub() + unsub2() + + self._send_time_changed(next_rising + offset) + self.hass.pool.block_till_done() + self.assertEqual(2, len(runs)) + self.assertEqual(1, len(offset_runs)) + def test_track_sunset(self): """Test track the sunset.""" latitude = 32.87336 @@ -232,11 +257,11 @@ class TestEventHelpers(unittest.TestCase): # Track sunset runs = [] - track_sunset(self.hass, lambda: runs.append(1)) + unsub = track_sunset(self.hass, lambda: runs.append(1)) offset_runs = [] offset = timedelta(minutes=30) - track_sunset(self.hass, lambda: offset_runs.append(1), offset) + unsub2 = track_sunset(self.hass, lambda: offset_runs.append(1), offset) # Run tests self._send_time_changed(next_setting - offset) @@ -254,6 +279,14 @@ class TestEventHelpers(unittest.TestCase): self.assertEqual(2, len(runs)) self.assertEqual(1, len(offset_runs)) + unsub() + unsub2() + + self._send_time_changed(next_setting + offset) + self.hass.pool.block_till_done() + self.assertEqual(2, len(runs)) + self.assertEqual(1, len(offset_runs)) + def _send_time_changed(self, now): """Send a time changed event.""" self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now}) @@ -262,7 +295,7 @@ class TestEventHelpers(unittest.TestCase): """Test periodic tasks per minute.""" specific_runs = [] - track_utc_time_change( + unsub = track_utc_time_change( self.hass, lambda x: specific_runs.append(1), minute='/5') self._send_time_changed(datetime(2014, 5, 24, 12, 0, 0)) @@ -277,11 +310,17 @@ class TestEventHelpers(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(2, len(specific_runs)) + unsub() + + self._send_time_changed(datetime(2014, 5, 24, 12, 5, 0)) + self.hass.pool.block_till_done() + self.assertEqual(2, len(specific_runs)) + def test_periodic_task_hour(self): """Test periodic tasks per hour.""" specific_runs = [] - track_utc_time_change( + unsub = track_utc_time_change( self.hass, lambda x: specific_runs.append(1), hour='/2') self._send_time_changed(datetime(2014, 5, 24, 22, 0, 0)) @@ -304,11 +343,17 @@ class TestEventHelpers(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(3, len(specific_runs)) + unsub() + + self._send_time_changed(datetime(2014, 5, 25, 2, 0, 0)) + self.hass.pool.block_till_done() + self.assertEqual(3, len(specific_runs)) + def test_periodic_task_day(self): """Test periodic tasks per day.""" specific_runs = [] - track_utc_time_change( + unsub = track_utc_time_change( self.hass, lambda x: specific_runs.append(1), day='/2') self._send_time_changed(datetime(2014, 5, 2, 0, 0, 0)) @@ -323,11 +368,17 @@ class TestEventHelpers(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(2, len(specific_runs)) + unsub() + + self._send_time_changed(datetime(2014, 5, 4, 0, 0, 0)) + self.hass.pool.block_till_done() + self.assertEqual(2, len(specific_runs)) + def test_periodic_task_year(self): """Test periodic tasks per year.""" specific_runs = [] - track_utc_time_change( + unsub = track_utc_time_change( self.hass, lambda x: specific_runs.append(1), year='/2') self._send_time_changed(datetime(2014, 5, 2, 0, 0, 0)) @@ -342,6 +393,12 @@ class TestEventHelpers(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(2, len(specific_runs)) + unsub() + + self._send_time_changed(datetime(2016, 5, 2, 0, 0, 0)) + self.hass.pool.block_till_done() + self.assertEqual(2, len(specific_runs)) + def test_periodic_task_wrong_input(self): """Test periodic tasks with wrong input.""" specific_runs = [] diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 5372b6a77d4..34f321776d6 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -43,6 +43,11 @@ class TestServiceHelpers(unittest.TestCase): 'entity_id': 'hello.world', 'data_template': { 'hello': '{{ \'goodbye\' }}', + 'data': { + 'value': '{{ \'complex\' }}', + 'simple': 'simple' + }, + 'list': ['{{ \'list\' }}', '2'], }, } runs = [] @@ -54,6 +59,9 @@ class TestServiceHelpers(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual('goodbye', runs[0].data['hello']) + self.assertEqual('complex', runs[0].data['data']['value']) + self.assertEqual('simple', runs[0].data['data']['simple']) + self.assertEqual('list', runs[0].data['list'][0]) def test_passing_variables_to_templates(self): """Test passing variables to templates.""" diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 266138d1fd5..64bac46264d 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -127,6 +127,30 @@ class TestUtilTemplate(unittest.TestCase): template.render(self.hass, '{{ %s | multiply(10) | round }}' % inp)) + def test_timestamp_custom(self): + """Test the timestamps to custom filter.""" + tests = [ + (None, None, None, 'None'), + (1469119144, None, True, '2016-07-21 16:39:04'), + (1469119144, '%Y', True, '2016'), + (1469119144, 'invalid', True, 'invalid'), + (dt_util.as_timestamp(dt_util.utcnow()), None, False, + dt_util.now().strftime('%Y-%m-%d %H:%M:%S')) + ] + + for inp, fmt, local, out in tests: + if fmt: + fil = 'timestamp_custom(\'{}\')'.format(fmt) + elif fmt and local: + fil = 'timestamp_custom(\'{0}\', {1})'.format(fmt, local) + else: + fil = 'timestamp_custom' + + self.assertEqual( + out, + template.render(self.hass, '{{ %s | %s }}' % (inp, fil)) + ) + def test_timestamp_local(self): """Test the timestamps to local filter.""" tests = { diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index fbd80760c12..f9ebaa634ff 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -137,7 +137,10 @@ class TestCheckConfig(unittest.TestCase): self.maxDiff = None with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('secret.yaml')) + config_path = get_test_config_dir('secret.yaml') + secrets_path = get_test_config_dir('secrets.yaml') + + res = check_config.check(config_path) change_yaml_files(res) # convert secrets OrderedDict to dict for assertequal @@ -148,7 +151,7 @@ class TestCheckConfig(unittest.TestCase): 'components': {'http': {'api_password': 'abc123', 'server_port': 8123}}, 'except': {}, - 'secret_cache': {'secrets.yaml': {'http_pw': 'abc123'}}, + 'secret_cache': {secrets_path: {'http_pw': 'abc123'}}, 'secrets': {'http_pw': 'abc123'}, - 'yaml_files': ['.../secret.yaml', 'secrets.yaml'] + 'yaml_files': ['.../secret.yaml', '.../secrets.yaml'] }, res) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index d41dc60ee15..8ad9d1cc409 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -177,6 +177,7 @@ class TestBootstrap: return_value=False) def test_component_not_installed_if_requirement_fails(self, mock_install): """Component setup should fail if requirement can't install.""" + self.hass.config.skip_pip = False loader.set_component( 'comp', MockModule('comp', requirements=['package==0.0.1'])) @@ -210,19 +211,19 @@ class TestBootstrap: deps = ['non_existing'] loader.set_component('comp', MockModule('comp', dependencies=deps)) - assert not bootstrap._setup_component(self.hass, 'comp', None) + assert not bootstrap._setup_component(self.hass, 'comp', {}) assert 'comp' not in self.hass.config.components self.hass.config.components.append('non_existing') - assert bootstrap._setup_component(self.hass, 'comp', None) + assert bootstrap._setup_component(self.hass, 'comp', {}) def test_component_failing_setup(self): """Test component that fails setup.""" loader.set_component( 'comp', MockModule('comp', setup=lambda hass, config: False)) - assert not bootstrap._setup_component(self.hass, 'comp', None) + assert not bootstrap._setup_component(self.hass, 'comp', {}) assert 'comp' not in self.hass.config.components def test_component_exception_setup(self): @@ -233,7 +234,7 @@ class TestBootstrap: loader.set_component('comp', MockModule('comp', setup=exception_setup)) - assert not bootstrap._setup_component(self.hass, 'comp', None) + assert not bootstrap._setup_component(self.hass, 'comp', {}) assert 'comp' not in self.hass.config.components def test_home_assistant_core_config_validation(self): @@ -279,7 +280,7 @@ class TestBootstrap: loader.set_component( 'switch.platform_a', - MockPlatform('comp_b', platform_schema=platform_schema)) + MockPlatform(platform_schema=platform_schema)) assert not bootstrap.setup_component(self.hass, 'switch', { 'switch': { diff --git a/tests/test_core.py b/tests/test_core.py index aa3cdd2aecc..76c82252d30 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -166,14 +166,37 @@ class TestEventBus(unittest.TestCase): self.assertEqual(old_count + 1, len(self.bus.listeners)) # Try deleting a non registered listener, nothing should happen - self.bus.remove_listener('test', lambda x: len) + self.bus._remove_listener('test', lambda x: len) # Remove listener - self.bus.remove_listener('test', listener) + self.bus._remove_listener('test', listener) self.assertEqual(old_count, len(self.bus.listeners)) # Try deleting listener while category doesn't exist either - self.bus.remove_listener('test', listener) + self.bus._remove_listener('test', listener) + + def test_unsubscribe_listener(self): + """Test unsubscribe listener from returned function.""" + self.bus._pool.add_worker() + calls = [] + + def listener(event): + """Mock listener.""" + calls.append(event) + + unsub = self.bus.listen('test', listener) + + self.bus.fire('test') + self.bus._pool.block_till_done() + + assert len(calls) == 1 + + unsub() + + self.bus.fire('event') + self.bus._pool.block_till_done() + + assert len(calls) == 1 def test_listen_once_event(self): """Test listen_once_event method.""" diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py index 4ce0def08ac..6b35e4f844c 100644 --- a/tests/util/test_yaml.py +++ b/tests/util/test_yaml.py @@ -3,59 +3,68 @@ import io import unittest import os import tempfile +from unittest.mock import patch + from homeassistant.exceptions import HomeAssistantError from homeassistant.util import yaml -import homeassistant.config as config_util -from tests.common import get_test_config_dir +from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file +from tests.common import get_test_config_dir, patch_yaml_files class TestYaml(unittest.TestCase): """Test util.yaml loader.""" + # pylint: disable=no-self-use,invalid-name def test_simple_list(self): """Test simple list.""" conf = "config:\n - simple\n - list" - with io.StringIO(conf) as f: - doc = yaml.yaml.safe_load(f) + with io.StringIO(conf) as file: + doc = yaml.yaml.safe_load(file) assert doc['config'] == ["simple", "list"] def test_simple_dict(self): """Test simple dict.""" conf = "key: value" - with io.StringIO(conf) as f: - doc = yaml.yaml.safe_load(f) + with io.StringIO(conf) as file: + doc = yaml.yaml.safe_load(file) assert doc['key'] == 'value' def test_duplicate_key(self): - """Test simple dict.""" - conf = "key: thing1\nkey: thing2" - try: - with io.StringIO(conf) as f: - yaml.yaml.safe_load(f) - except Exception: - pass - else: - assert 0 + """Test duplicate dict keys.""" + files = {YAML_CONFIG_FILE: 'key: thing1\nkey: thing2'} + with self.assertRaises(HomeAssistantError): + with patch_yaml_files(files): + load_yaml_config_file(YAML_CONFIG_FILE) + + def test_unhashable_key(self): + """Test an unhasable key.""" + files = {YAML_CONFIG_FILE: 'message:\n {{ states.state }}'} + with self.assertRaises(HomeAssistantError), \ + patch_yaml_files(files): + load_yaml_config_file(YAML_CONFIG_FILE) + + def test_no_key(self): + """Test item without an key.""" + files = {YAML_CONFIG_FILE: 'a: a\nnokeyhere'} + with self.assertRaises(HomeAssistantError), \ + patch_yaml_files(files): + yaml.load_yaml(YAML_CONFIG_FILE) def test_enviroment_variable(self): """Test config file with enviroment variable.""" os.environ["PASSWORD"] = "secret_password" conf = "password: !env_var PASSWORD" - with io.StringIO(conf) as f: - doc = yaml.yaml.safe_load(f) + with io.StringIO(conf) as file: + doc = yaml.yaml.safe_load(file) assert doc['password'] == "secret_password" del os.environ["PASSWORD"] def test_invalid_enviroment_variable(self): """Test config file with no enviroment variable sat.""" conf = "password: !env_var PASSWORD" - try: - with io.StringIO(conf) as f: - yaml.yaml.safe_load(f) - except Exception: - pass - else: - assert 0 + with self.assertRaises(HomeAssistantError): + with io.StringIO(conf) as file: + yaml.yaml.safe_load(file) def test_include_yaml(self): """Test include yaml.""" @@ -63,8 +72,8 @@ class TestYaml(unittest.TestCase): include_file.write(b"value") include_file.seek(0) conf = "key: !include {}".format(include_file.name) - with io.StringIO(conf) as f: - doc = yaml.yaml.safe_load(f) + with io.StringIO(conf) as file: + doc = yaml.yaml.safe_load(file) assert doc["key"] == "value" def test_include_dir_list(self): @@ -79,8 +88,8 @@ class TestYaml(unittest.TestCase): file_2.write(b"two") file_2.close() conf = "key: !include_dir_list {}".format(include_dir) - with io.StringIO(conf) as f: - doc = yaml.yaml.safe_load(f) + with io.StringIO(conf) as file: + doc = yaml.yaml.safe_load(file) assert sorted(doc["key"]) == sorted(["one", "two"]) def test_include_dir_named(self): @@ -98,8 +107,8 @@ class TestYaml(unittest.TestCase): correct = {} correct[os.path.splitext(os.path.basename(file_1.name))[0]] = "one" correct[os.path.splitext(os.path.basename(file_2.name))[0]] = "two" - with io.StringIO(conf) as f: - doc = yaml.yaml.safe_load(f) + with io.StringIO(conf) as file: + doc = yaml.yaml.safe_load(file) assert doc["key"] == correct def test_include_dir_merge_list(self): @@ -114,8 +123,8 @@ class TestYaml(unittest.TestCase): file_2.write(b"- two\n- three") file_2.close() conf = "key: !include_dir_merge_list {}".format(include_dir) - with io.StringIO(conf) as f: - doc = yaml.yaml.safe_load(f) + with io.StringIO(conf) as file: + doc = yaml.yaml.safe_load(file) assert sorted(doc["key"]) == sorted(["one", "two", "three"]) def test_include_dir_merge_named(self): @@ -130,23 +139,25 @@ class TestYaml(unittest.TestCase): file_2.write(b"key2: two\nkey3: three") file_2.close() conf = "key: !include_dir_merge_named {}".format(include_dir) - with io.StringIO(conf) as f: - doc = yaml.yaml.safe_load(f) + with io.StringIO(conf) as file: + doc = yaml.yaml.safe_load(file) assert doc["key"] == { "key1": "one", "key2": "two", "key3": "three" } +FILES = {} + def load_yaml(fname, string): """Write a string to file and return the parsed yaml.""" - with open(fname, 'w') as file: - file.write(string) - return config_util.load_yaml_config_file(fname) + FILES[fname] = string + with patch_yaml_files(FILES): + return load_yaml_config_file(fname) -class FakeKeyring(): +class FakeKeyring(): # pylint: disable=too-few-public-methods """Fake a keyring class.""" def __init__(self, secrets_dict): @@ -162,20 +173,16 @@ class FakeKeyring(): class TestSecrets(unittest.TestCase): """Test the secrets parameter in the yaml utility.""" + # pylint: disable=protected-access,invalid-name def setUp(self): # pylint: disable=invalid-name """Create & load secrets file.""" config_dir = get_test_config_dir() yaml.clear_secret_cache() - self._yaml_path = os.path.join(config_dir, - config_util.YAML_CONFIG_FILE) + self._yaml_path = os.path.join(config_dir, YAML_CONFIG_FILE) self._secret_path = os.path.join(config_dir, yaml._SECRET_YAML) self._sub_folder_path = os.path.join(config_dir, 'subFolder') - if not os.path.exists(self._sub_folder_path): - os.makedirs(self._sub_folder_path) self._unrelated_path = os.path.join(config_dir, 'unrelated') - if not os.path.exists(self._unrelated_path): - os.makedirs(self._unrelated_path) load_yaml(self._secret_path, 'http_pw: pwhttp\n' @@ -194,12 +201,7 @@ class TestSecrets(unittest.TestCase): def tearDown(self): # pylint: disable=invalid-name """Clean up secrets.""" yaml.clear_secret_cache() - for path in [self._yaml_path, self._secret_path, - os.path.join(self._sub_folder_path, 'sub.yaml'), - os.path.join(self._sub_folder_path, yaml._SECRET_YAML), - os.path.join(self._unrelated_path, yaml._SECRET_YAML)]: - if os.path.isfile(path): - os.remove(path) + FILES.clear() def test_secrets_from_yaml(self): """Did secrets load ok.""" @@ -263,3 +265,12 @@ class TestSecrets(unittest.TestCase): """Ensure logger: debug was removed.""" with self.assertRaises(yaml.HomeAssistantError): load_yaml(self._yaml_path, 'api_password: !secret logger') + + @patch('homeassistant.util.yaml._LOGGER.error') + def test_bad_logger_value(self, mock_error): + """Ensure logger: debug was removed.""" + yaml.clear_secret_cache() + load_yaml(self._secret_path, 'logger: info\npw: abc') + load_yaml(self._yaml_path, 'api_password: !secret pw') + assert mock_error.call_count == 1, \ + "Expected an error about logger: value" From 36785296ce0fcc2ce2b00e7b1ce52351652e806d Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Sun, 11 Sep 2016 03:07:13 -0400 Subject: [PATCH 02/13] Fixed voluptuous to accept string instead positive_int for CODE on Simplisafe (#3310) --- homeassistant/components/alarm_control_panel/simplisafe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/alarm_control_panel/simplisafe.py b/homeassistant/components/alarm_control_panel/simplisafe.py index 82927246ec6..38128489ba0 100644 --- a/homeassistant/components/alarm_control_panel/simplisafe.py +++ b/homeassistant/components/alarm_control_panel/simplisafe.py @@ -26,7 +26,7 @@ DEFAULT_NAME = 'SimpliSafe' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_CODE): cv.positive_int, + vol.Optional(CONF_CODE): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) From 7bce8bc33f5db48f87f23ff28b08da5f7bc20c6f Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Mon, 12 Sep 2016 06:46:14 +0200 Subject: [PATCH 03/13] Revert only add 1 device (#3324) --- homeassistant/components/climate/zwave.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index 0ba85105c18..6c08bf391d8 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -70,8 +70,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): node = zwave.NETWORK.nodes[discovery_info[ATTR_NODE_ID]] value = node.values[discovery_info[ATTR_VALUE_ID]] value.set_change_verified(False) - if value.index != 1: # Only add 1 device - return add_devices([ZWaveClimate(value, temp_unit)]) _LOGGER.debug("discovery_info=%s and zwave.NETWORK=%s", discovery_info, zwave.NETWORK) From d6ca93042784aea56630ee09661bed27bd493b2a Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Sun, 11 Sep 2016 22:53:05 -0600 Subject: [PATCH 04/13] Automatic Device Tracker Bug Fix (#3330) * Iterate over items * Pass display name as host name --- homeassistant/components/device_tracker/__init__.py | 2 +- homeassistant/components/device_tracker/automatic.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 4247213087b..236fde6fb3f 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -338,7 +338,7 @@ class Device(Entity): attr[ATTR_BATTERY] = self.battery if self.attributes: - for key, value in self.attributes: + for key, value in self.attributes.items(): attr[key] = value return attr diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 927c515b3a5..7855323ba06 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -142,6 +142,7 @@ class AutomaticDeviceScanner(object): for vehicle in self.last_results: dev_id = vehicle.get('id') + host_name = vehicle.get('display_name') attrs = { 'fuel_level': vehicle.get('fuel_level_percent') @@ -149,6 +150,7 @@ class AutomaticDeviceScanner(object): kwargs = { 'dev_id': dev_id, + 'host_name': host_name, 'mac': dev_id, ATTR_ATTRIBUTES: attrs } From f0ec51711c85f94753577ffaeb79cf849da06cdc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 11 Sep 2016 22:25:01 -0700 Subject: [PATCH 05/13] Bugfix group order (#3323) * Add ordered dict config validator * Have group component use ordered dict config validator * Improve config_validation testing * update doc string config_validation.ordered_dict * validate full dict entries * Further simplify ordered_dict validator. * Lint fix --- homeassistant/components/group.py | 4 +- homeassistant/helpers/config_validation.py | 22 ++++++++++ tests/components/test_group.py | 17 +++++--- tests/helpers/test_config_validation.py | 50 ++++++++++++++++++++++ 4 files changed, 84 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py index c4cd177925d..41901d87e86 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -46,12 +46,12 @@ def _conf_preprocess(value): CONFIG_SCHEMA = vol.Schema({ - DOMAIN: {cv.match_all: vol.Schema(vol.All(_conf_preprocess, { + DOMAIN: cv.ordered_dict(vol.All(_conf_preprocess, { vol.Optional(CONF_ENTITIES): vol.Any(cv.entity_ids, None), CONF_VIEW: cv.boolean, CONF_NAME: cv.string, CONF_ICON: cv.icon, - }))} + }, cv.match_all)) }, extra=vol.ALLOW_EXTRA) # List of ON/OFF state tuples for groupable states diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 1be157c789d..009736024a1 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1,4 +1,5 @@ """Helpers for config validation using voluptuous.""" +from collections import OrderedDict from datetime import timedelta import os from urllib.parse import urlparse @@ -290,6 +291,27 @@ def url(value: Any) -> str: raise vol.Invalid('invalid url') +def ordered_dict(value_validator, key_validator=match_all): + """Validate an ordered dict validator that maintains ordering. + + value_validator will be applied to each value of the dictionary. + key_validator (optional) will be applied to each key of the dictionary. + """ + item_validator = vol.Schema({key_validator: value_validator}) + + def validator(value): + """Validate ordered dict.""" + config = OrderedDict() + + for key, val in value.items(): + v_res = item_validator({key: val}) + config.update(v_res) + + return config + + return validator + + # Validator helpers def key_dependency(key, dependency): diff --git a/tests/components/test_group.py b/tests/components/test_group.py index e82190a3f29..6c601a411fb 100644 --- a/tests/components/test_group.py +++ b/tests/components/test_group.py @@ -1,5 +1,6 @@ """The tests for the Group components.""" # pylint: disable=protected-access,too-many-public-methods +from collections import OrderedDict import unittest from unittest.mock import patch @@ -220,16 +221,16 @@ class TestComponentsGroup(unittest.TestCase): test_group = group.Group( self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False) - _setup_component(self.hass, 'group', {'group': { - 'second_group': { + group_conf = OrderedDict() + group_conf['second_group'] = { 'entities': 'light.Bowl, ' + test_group.entity_id, 'icon': 'mdi:work', 'view': True, - }, - 'test_group': 'hello.world,sensor.happy', - 'empty_group': {'name': 'Empty Group', 'entities': None}, - } - }) + } + group_conf['test_group'] = 'hello.world,sensor.happy' + group_conf['empty_group'] = {'name': 'Empty Group', 'entities': None} + + _setup_component(self.hass, 'group', {'group': group_conf}) group_state = self.hass.states.get( group.ENTITY_ID_FORMAT.format('second_group')) @@ -241,6 +242,7 @@ class TestComponentsGroup(unittest.TestCase): group_state.attributes.get(ATTR_ICON)) self.assertTrue(group_state.attributes.get(group.ATTR_VIEW)) self.assertTrue(group_state.attributes.get(ATTR_HIDDEN)) + self.assertEqual(1, group_state.attributes.get(group.ATTR_ORDER)) group_state = self.hass.states.get( group.ENTITY_ID_FORMAT.format('test_group')) @@ -251,6 +253,7 @@ class TestComponentsGroup(unittest.TestCase): self.assertIsNone(group_state.attributes.get(ATTR_ICON)) self.assertIsNone(group_state.attributes.get(group.ATTR_VIEW)) self.assertIsNone(group_state.attributes.get(ATTR_HIDDEN)) + self.assertEqual(2, group_state.attributes.get(group.ATTR_ORDER)) def test_groups_get_unique_names(self): """Two groups with same name should both have a unique entity id.""" diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index d9da2c51da7..60b14757378 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1,3 +1,5 @@ +"""Test config validators.""" +from collections import OrderedDict from datetime import timedelta import os import tempfile @@ -367,3 +369,51 @@ def test_has_at_least_one_key(): for value in ({'beer': None}, {'soda': None}): schema(value) + + +def test_ordered_dict_order(): + """Test ordered_dict validator.""" + schema = vol.Schema(cv.ordered_dict(int, cv.string)) + + val = OrderedDict() + val['first'] = 1 + val['second'] = 2 + + validated = schema(val) + + assert isinstance(validated, OrderedDict) + assert ['first', 'second'] == list(validated.keys()) + + +def test_ordered_dict_key_validator(): + """Test ordered_dict key validator.""" + schema = vol.Schema(cv.ordered_dict(cv.match_all, cv.string)) + + with pytest.raises(vol.Invalid): + schema({None: 1}) + + schema({'hello': 'world'}) + + schema = vol.Schema(cv.ordered_dict(cv.match_all, int)) + + with pytest.raises(vol.Invalid): + schema({'hello': 1}) + + schema({1: 'works'}) + + +def test_ordered_dict_value_validator(): + """Test ordered_dict validator.""" + schema = vol.Schema(cv.ordered_dict(cv.string)) + + with pytest.raises(vol.Invalid): + schema({'hello': None}) + + schema({'hello': 'world'}) + + schema = vol.Schema(cv.ordered_dict(int)) + + with pytest.raises(vol.Invalid): + schema({'hello': 'world'}) + + schema({'hello': 5}) From 0cfa5e5f674d7a67ed4ee40d2e4442efc18fee0c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 11 Sep 2016 22:28:53 -0700 Subject: [PATCH 06/13] Version bump to 0.28.1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index eb8b65df998..797fd3108b9 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 28 -PATCH_VERSION = '0' +PATCH_VERSION = '1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4) From dd4611064f907b567f4865219ddc8c0a96ddfd7d Mon Sep 17 00:00:00 2001 From: David-Leon Pohl Date: Tue, 13 Sep 2016 03:28:11 +0200 Subject: [PATCH 07/13] Bugfix pilight component (#3355) * BUG Message data cannot be changed thus use voluptuous to ensure format * Pilight daemon expects JSON serializable data Thus dict is needed and not a mapping proxy. * Add explanation why dict as message data is needed * Use more obvious voluptuous validation scheme * Pylint: Trailing whitespace --- homeassistant/components/pilight.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/pilight.py b/homeassistant/components/pilight.py index 764b972d393..3475a6be65a 100644 --- a/homeassistant/components/pilight.py +++ b/homeassistant/components/pilight.py @@ -10,7 +10,6 @@ import socket import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.config_validation import ensure_list from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT, CONF_WHITELIST) @@ -29,7 +28,10 @@ EVENT = 'pilight_received' # The pilight code schema depends on the protocol # Thus only require to have the protocol information -RF_CODE_SCHEMA = vol.Schema({vol.Required(ATTR_PROTOCOL): cv.string}, +# Ensure that protocol is in a list otherwise segfault in pilight-daemon +# https://github.com/pilight/pilight/issues/296 +RF_CODE_SCHEMA = vol.Schema({vol.Required(ATTR_PROTOCOL): + vol.All(cv.ensure_list, [cv.string])}, extra=vol.ALLOW_EXTRA) SERVICE_NAME = 'send' @@ -71,12 +73,9 @@ def setup(hass, config): def send_code(call): """Send RF code to the pilight-daemon.""" - message_data = call.data - - # Patch data because of bug: - # https://github.com/pilight/pilight/issues/296 - # Protocol has to be in a list otherwise segfault in pilight-daemon - message_data['protocol'] = ensure_list(message_data['protocol']) + # Change type to dict from mappingproxy + # since data has to be JSON serializable + message_data = dict(call.data) try: pilight_client.send_code(message_data) From bc600b8f321edb9e6ee01830701288e41f4fb0fa Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 13 Sep 2016 03:21:35 +0200 Subject: [PATCH 08/13] Bugfix voluptuous on recorder (#3350) --- homeassistant/components/recorder/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 5e4415d81a0..8f373700165 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -20,6 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.const import (EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, QueryType import homeassistant.util.dt as dt_util @@ -40,10 +41,9 @@ QUERY_RETRY_WAIT = 0.1 CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(CONF_PURGE_DAYS): vol.All(vol.Coerce(int), - vol.Range(min=1)), - # pylint: disable=no-value-for-parameter - vol.Optional(CONF_DB_URL): vol.Url(), + vol.Optional(CONF_PURGE_DAYS): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_DB_URL): cv.string, }) }, extra=vol.ALLOW_EXTRA) From 07148fc5805d2d12ede74846b1b82b5097e09599 Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Tue, 13 Sep 2016 03:23:18 +0200 Subject: [PATCH 09/13] Missing garage door detection (#3349) --- homeassistant/components/cover/wink.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cover/wink.py b/homeassistant/components/cover/wink.py index 9b76e234303..59676b1f6a7 100644 --- a/homeassistant/components/cover/wink.py +++ b/homeassistant/components/cover/wink.py @@ -28,8 +28,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): pywink.set_bearer_token(token) - add_devices(WinkCoverDevice(shade) for shade, door in + add_devices(WinkCoverDevice(shade) for shade in pywink.get_shades()) + add_devices(WinkCoverDevice(door) for door in + pywink.get_garage_doors()) class WinkCoverDevice(WinkDevice, CoverDevice): From c6b6ab1b79216dd507eae4b30446fe0e3de1bb1b Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Mon, 12 Sep 2016 17:40:46 +0200 Subject: [PATCH 10/13] Bugfix ecobee: inverted high and low temps and enforce int to temps (#3325) * inverted high and low temps * Looks like somethings are mixed up * Added debugentires * Added debugentires 2 * Enforce int on temperatures --- homeassistant/components/climate/ecobee.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py index 5d78aeb8597..21a2c89c31a 100644 --- a/homeassistant/components/climate/ecobee.py +++ b/homeassistant/components/climate/ecobee.py @@ -116,9 +116,6 @@ class Thermostat(ClimateDevice): return self.target_temperature_low elif self.operation_mode == 'cool': return self.target_temperature_high - else: - return (self.target_temperature_low + - self.target_temperature_high) / 2 @property def target_temperature_low(self): @@ -223,19 +220,27 @@ class Thermostat(ClimateDevice): """Set new target temperature.""" if kwargs.get(ATTR_TEMPERATURE) is not None: temperature = kwargs.get(ATTR_TEMPERATURE) - low_temp = temperature - 1 - high_temp = temperature + 1 + low_temp = int(temperature) + high_temp = int(temperature) if kwargs.get(ATTR_TARGET_TEMP_LOW) is not None and \ kwargs.get(ATTR_TARGET_TEMP_HIGH) is not None: - low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) - high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) + high_temp = int(kwargs.get(ATTR_TARGET_TEMP_LOW)) + low_temp = int(kwargs.get(ATTR_TARGET_TEMP_HIGH)) if self.hold_temp: self.data.ecobee.set_hold_temp(self.thermostat_index, low_temp, high_temp, "indefinite") + _LOGGER.debug("Setting ecobee hold_temp to: low=%s, is=%s, " + "high=%s, is=%s", low_temp, isinstance( + low_temp, (int, float)), high_temp, + isinstance(high_temp, (int, float))) else: self.data.ecobee.set_hold_temp(self.thermostat_index, low_temp, high_temp) + _LOGGER.debug("Setting ecobee temp to: low=%s, is=%s, " + "high=%s, is=%s", low_temp, isinstance( + low_temp, (int, float)), high_temp, + isinstance(high_temp, (int, float))) def set_operation_mode(self, operation_mode): """Set HVAC mode (auto, auxHeatOnly, cool, heat, off).""" From b43bf62347f10086fb1dcd3b73d6ffb5cd895a69 Mon Sep 17 00:00:00 2001 From: Nick Vella Date: Tue, 13 Sep 2016 11:31:44 +1000 Subject: [PATCH 11/13] Add open/closed state for open_cover and close_cover in SERVICE_TO_STATE (#3180) * Add open/closed state mapping for open_cover and close_cover * Add 'open', 'closed' for open/close_cover_tilt * Revert "Add 'open', 'closed' for open/close_cover_tilt" This reverts commit e45582d4394a33feedfce190a1dba96473d24825. --- homeassistant/helpers/state.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index 9c6e797acd1..4935251db7d 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -29,10 +29,10 @@ from homeassistant.const import ( SERVICE_CLOSE, SERVICE_LOCK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_SEEK, SERVICE_MOVE_DOWN, SERVICE_MOVE_UP, SERVICE_OPEN, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_UNLOCK, SERVICE_VOLUME_MUTE, - SERVICE_VOLUME_SET, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, STATE_CLOSED, STATE_LOCKED, - STATE_OFF, STATE_ON, STATE_OPEN, STATE_PAUSED, STATE_PLAYING, - STATE_UNKNOWN, STATE_UNLOCKED) + SERVICE_VOLUME_SET, SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, STATE_CLOSED, STATE_LOCKED, STATE_OFF, STATE_ON, + STATE_OPEN, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, STATE_UNLOCKED) from homeassistant.core import State _LOGGER = logging.getLogger(__name__) @@ -77,6 +77,8 @@ SERVICE_TO_STATE = { SERVICE_OPEN: STATE_OPEN, SERVICE_MOVE_UP: STATE_OPEN, SERVICE_MOVE_DOWN: STATE_CLOSED, + SERVICE_OPEN_COVER: STATE_OPEN, + SERVICE_CLOSE_COVER: STATE_CLOSED } From b9154158e8f182376f02608e7a8e28bdd3779ed5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 12 Sep 2016 18:33:36 -0700 Subject: [PATCH 12/13] Version bump to 0.28.2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 797fd3108b9..392a10aedb9 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 28 -PATCH_VERSION = '1' +PATCH_VERSION = '2' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4) From 2b1416c514ec3bda1852965d94a94d0e3390c9bc Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Mon, 12 Sep 2016 20:59:34 -0600 Subject: [PATCH 13/13] Automatic polling (#3360) * Test updating automatic * Scan interval * Schedule scan every time delta * Pass around has * Recursive issue * Method invocation * Oops * Set up poll * Default argument value * Unused import * Semicolon * Fix tests * Linting * Unneeded throttle as it's handled by time event * Use track time change event listener * Disable lint rule * Attribute removed - removing test * Debug instead of info * Unused import --- .../components/device_tracker/automatic.py | 31 +++++++------------ .../device_tracker/test_automatic.py | 26 ++++------------ 2 files changed, 17 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 7855323ba06..27bd9c6b477 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -15,12 +15,11 @@ from homeassistant.components.device_tracker import (PLATFORM_SCHEMA, ATTR_ATTRIBUTES) from homeassistant.const import CONF_USERNAME, CONF_PASSWORD import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle, datetime as dt_util +from homeassistant.helpers.event import track_utc_time_change +from homeassistant.util import datetime as dt_util _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30) - CONF_CLIENT_ID = 'client_id' CONF_SECRET = 'secret' CONF_DEVICES = 'devices' @@ -53,7 +52,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_scanner(hass, config: dict, see): """Validate the configuration and return an Automatic scanner.""" try: - AutomaticDeviceScanner(config, see) + AutomaticDeviceScanner(hass, config, see) except requests.HTTPError as err: _LOGGER.error(str(err)) return False @@ -61,11 +60,14 @@ def setup_scanner(hass, config: dict, see): return True +# pylint: disable=too-many-instance-attributes +# pylint: disable=too-few-public-methods class AutomaticDeviceScanner(object): """A class representing an Automatic device.""" - def __init__(self, config: dict, see) -> None: + def __init__(self, hass, config: dict, see) -> None: """Initialize the automatic device scanner.""" + self.hass = hass self._devices = config.get(CONF_DEVICES, None) self._access_token_payload = { 'username': config.get(CONF_USERNAME), @@ -81,20 +83,10 @@ class AutomaticDeviceScanner(object): self.last_trips = {} self.see = see - self.scan_devices() - - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" self._update_info() - return [item['id'] for item in self.last_results] - - def get_device_name(self, device): - """Get the device name from id.""" - vehicle = [item['display_name'] for item in self.last_results - if item['id'] == device] - - return vehicle[0] + track_utc_time_change(self.hass, self._update_info, + second=range(0, 60, 30)) def _update_headers(self): """Get the access token from automatic.""" @@ -114,10 +106,9 @@ class AutomaticDeviceScanner(object): 'Authorization': 'Bearer {}'.format(access_token) } - @Throttle(MIN_TIME_BETWEEN_SCANS) - def _update_info(self) -> None: + def _update_info(self, now=None) -> None: """Update the device info.""" - _LOGGER.info('Updating devices') + _LOGGER.debug('Updating devices %s', now) self._update_headers() response = requests.get(URL_VEHICLES, headers=self._headers) diff --git a/tests/components/device_tracker/test_automatic.py b/tests/components/device_tracker/test_automatic.py index e026d91a43c..2e476ac742d 100644 --- a/tests/components/device_tracker/test_automatic.py +++ b/tests/components/device_tracker/test_automatic.py @@ -6,8 +6,9 @@ import unittest from unittest.mock import patch from homeassistant.components.device_tracker.automatic import ( - URL_AUTHORIZE, URL_VEHICLES, URL_TRIPS, setup_scanner, - AutomaticDeviceScanner) + URL_AUTHORIZE, URL_VEHICLES, URL_TRIPS, setup_scanner) + +from tests.common import get_test_home_assistant _LOGGER = logging.getLogger(__name__) @@ -205,6 +206,7 @@ class TestAutomatic(unittest.TestCase): def setUp(self): """Set up test data.""" + self.hass = get_test_home_assistant() def tearDown(self): """Tear down test data.""" @@ -221,7 +223,7 @@ class TestAutomatic(unittest.TestCase): 'secret': CLIENT_SECRET } - self.assertFalse(setup_scanner(None, config, self.see_mock)) + self.assertFalse(setup_scanner(self.hass, config, self.see_mock)) @patch('requests.get', side_effect=mocked_requests) @patch('requests.post', side_effect=mocked_requests) @@ -235,20 +237,4 @@ class TestAutomatic(unittest.TestCase): 'secret': CLIENT_SECRET } - self.assertTrue(setup_scanner(None, config, self.see_mock)) - - @patch('requests.get', side_effect=mocked_requests) - @patch('requests.post', side_effect=mocked_requests) - def test_device_attributes(self, mock_get, mock_post): - """Test device attributes are set on load.""" - config = { - 'platform': 'automatic', - 'username': VALID_USERNAME, - 'password': PASSWORD, - 'client_id': CLIENT_ID, - 'secret': CLIENT_SECRET - } - - scanner = AutomaticDeviceScanner(config, self.see_mock) - - self.assertEqual(DISPLAY_NAME, scanner.get_device_name('vid')) + self.assertTrue(setup_scanner(self.hass, config, self.see_mock))